Author SHA1 Message Date
Talon 186fe6dbb6 Fix iOS broadcast pump mapping churn
Build and test / test (macos-latest) (push) Waiting to run
Build and test / test (ubuntu-24.04) (push) Waiting to run
Build and test / test (windows-latest) (push) Waiting to run
Build and test / apple-client (push) Waiting to run
The screen-audio pump opened and disposed a memory mapping plus its container
lookup and path strings on every 5 ms tick - about 200 mapping pairs per
second of steady allocation that churned the GC under long calls. The producer
opens the ring with O_CREAT and never replaces it, so the pump now keeps one
mapping across ticks and rebuilds it only when the ring file disappears or a
drain fails.
2026-09-23 21:47:42 +02:00
Talon f348574bc1 Fix iOS background audio pacing
Build and test / test (macos-latest) (push) Waiting to run
Build and test / test (ubuntu-24.04) (push) Waiting to run
Build and test / test (windows-latest) (push) Waiting to run
Build and test / apple-client (push) Waiting to run
Sleep-paced threads stall for hundreds of milliseconds when iOS coalesces a
backgrounded app's wakeups, and the deadline resets that discarded the deficit
left the capture backlog queued until its ring overflowed: regular dropouts
that worsen the longer the app stays backgrounded. Pace both 20 ms hands-offs
from the AVAudioSourceNode render callback instead (mix via Audio.RunCycle
with deviceClockedAudio, capture handoff from the ring), wake the UDP sender
on a queue signal instead of a 1 ms poll, and let stalled consumers drop their
backlog to the buffer target instead of ratcheting it.
2026-09-23 21:37:24 +02:00
Talon 1487f2673b Fix iOS app icon and extension bundle version validation
Declare XSAppIconAssets in the app manifest so actool receives --app-icon
and the bundle carries CFBundleIconName, and always pass both Xcode version
placeholders to the ReplayKit extension so an empty build number cannot
drop CFBundleVersion and fail installd.
2026-09-23 21:36:34 +02:00
Talon ea76d5157a Improve iOS voice stability and user audio controls
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
2026-09-22 22:07:00 +02:00
Talon 7b0c003ad4 Add adaptive packet loss handling
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
2026-09-22 16:54:49 +02:00
Talon 7c4708c3de Persist channel DRED settings
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
2026-09-22 14:11:36 +02:00
Talon 8471eac363 Fix Windows channel tree ordering
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
2026-09-21 19:47:14 +02:00
Talon 4aa0aa4fbd Fix iOS stereo microphone capture
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
2026-09-21 18:04:00 +02:00
Talon 0372baf589 Fix iOS voice capture and screen sharing
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
2026-09-21 14:14:15 +02:00
Talon 35617e9708 Add signed iOS release workflow
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
2026-09-21 04:05:40 +02:00
Talon cf808483e0 Fix physical iOS device deployment
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
2026-09-21 03:10:25 +02:00
Talon 4fac7af140 Fix macOS publish bundle signing
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
2026-09-21 02:53:27 +02:00
Talon 239e8e9013 Fix ad-hoc macOS publishing
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
2026-09-21 02:14:24 +02:00
Talon 6de95ddaa1 Regenerate runtime publish locks
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
2026-09-21 02:12:03 +02:00
Talon 9be33f357d Fix stereo capture paths 2026-09-21 02:11:33 +02:00
Talon 08e6c5930a Retire legacy implementations and flatten managed layout
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
2026-09-21 00:11:32 +02:00
Talon dd811a0bb8 Fix audio clock drift and adaptive jitter buffering
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
2026-09-20 16:26:03 +02:00
Talon 3ee0df11a9 Fix managed iOS native media linking
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
2026-09-20 03:04:12 +02:00
Talon 57a63cc1cd Publish Windows and Linux servers together
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
2026-09-20 01:15:52 +02:00
Talon b79cbeff44 Fix managed package lock isolation
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
2026-09-19 22:55:48 +02:00
Talon c9ed832459 Retire legacy sources and verify managed iOS deployment
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
2026-09-19 22:40:48 +02:00
Talon 42e3bbe14c Keep managed iOS audio active in background
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-19 20:09:00 +02:00
Talon 9fc5598e8e Bring managed iOS client to feature parity
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-19 19:33:10 +02:00
Talon c6715028c1 Add managed UIKit iOS client
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-19 15:43:37 +02:00
Talon d0a72176ba Extend managed macOS client toward feature parity
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-19 00:39:04 +02:00
Talon 310c0f09dd Complete managed macOS platform bring-up
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-18 18:51:53 +02:00
Talon ab460ae12b Add private messaging to managed macOS client
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-16 22:24:52 +02:00
Talon 28f1dbb991 Store macOS account passwords in Keychain
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-16 22:22:03 +02:00
Talon 5a30018aeb Prompt for protected macOS channels
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-16 22:16:18 +02:00
Talon 52f7f51e59 Add managed server profiles and account login
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-16 22:13:34 +02:00
Talon edd7783a5c Add managed macOS Core Audio voice path
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-16 22:05:40 +02:00
Talon e5ff484cac Start managed macOS AppKit client
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-16 17:34:16 +02:00
Talon 7be93e81a1 Package managed server for Linux production
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-16 17:08:17 +02:00
Talon 48c754aed9 Add managed console client and interop scenarios
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-16 16:55:17 +02:00
Talon 82ad4c2811 Port managed client audio and Windows application
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-16 16:48:06 +02:00
Talon 5a226ba543 Harden managed server deployment and authentication 2026-09-15 23:24:11 +02:00
Talon 653131b876 Add managed channel administration and moderation
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-15 23:11:09 +02:00
Talon 274b85025c Add configurable media-aware managed session reaping 2026-09-15 22:58:16 +02:00
Talon 05eacb3092 Add encrypted managed UDP relay and native voice conformance 2026-09-15 22:53:54 +02:00
Talon 4067bab7c2 Add managed codec DSP and initial control server 2026-09-15 22:51:33 +02:00
Talon 2df79cdd4c Add managed TLS interoperability and persisted credentials
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-15 18:04:20 +02:00
Talon b76181d9fb Start .NET rewrite with wire and media crypto conformance
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-15 17:54:16 +02:00
TalonandClaude Opus 5 c6c003b8a7 docs: add the .NET/C# porting plan
Proposal for replacing the C++ core, C++ server, and Swift macOS/iOS
clients with a single .NET 10 / C# codebase.

Covers the dependency map (8 vcpkg deps + 1 vendored -> 3 native libs),
the real-time-audio design, per-client strategy, a test-porting plan for
all 29 ctest cases, doc-sync work, an 11-phase migration, and a risk
register.

Two findings drive the shape of the plan:

- SslStream has no RFC 5705 keying-material exporter, which the media
  AEAD key derivation depends on (docs/security.md 2). The API is an
  unapproved proposal and SChannel structurally cannot export secrets.
  Recommends BouncyCastle's managed TLS 1.3 stack, which does implement
  the exporter and keeps the wire format byte-compatible with the C++
  implementation -- so the existing tree stays usable as a conformance
  oracle throughout the port. Protocol-v3 in-band media keys documented
  as the fallback.

- The iOS ReplayKit broadcast upload extension stays in Swift: 50 MB
  jetsam cap plus an unsupported extension type in .NET for iOS, and it
  already doesn't link the core. Leaves one Swift file plus the shared
  App Group ring.

Indexed in docs/README.md. Nothing here is implemented yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 03:19:59 +02:00
Talon 4f71b784fe docs: condense implementation comments
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
2026-07-23 13:37:05 +02:00
Talon 575e2907d0 Cleanup root cmakelists
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
2026-07-03 15:59:12 +01:00
Talon f687370e47 Cleanup voicecat.cpp impl comments
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
2026-07-03 15:56:14 +01:00
Talon 552ffceb66 Cleanup session/
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
2026-07-03 15:55:17 +01:00
Talon a4b3838125 Cleanup protocol/ comments
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
2026-07-03 15:52:46 +01:00
Talon eafa5eb90c Cleanup net/ comments
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
2026-07-03 15:49:32 +01:00
Talon 158a2df062 Cleanup crypto comments
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
2026-07-03 15:37:39 +01:00
Talon 826b3bfb86 Cleanup client and worker pool comments
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
2026-07-03 15:29:09 +01:00
Talon cc81d19d02 Cleanup audio engine and codec comments
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
2026-07-03 14:59:26 +01:00
Talon 8844325efa Cleanup audio engine comments
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
2026-07-03 13:13:35 +01:00
Talon bd844e4710 Cleanup core header and proto code
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
2026-07-03 12:46:51 +01:00
TalonandClaude Sonnet 5 5c03e5f261 build: bundle vcpkg as a git submodule, pinned to the manifest baseline
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
Adds vcpkg as a submodule at vcpkg/, pinned to the exact commit vcpkg.json
already declares as builtin-baseline, so the bundled checkout and the
manifest's resolved port versions can never drift apart.

cmake/voicecat-toolchain.cmake, scripts/common.sh, and
clients/apple/scripts/build-xcframework.sh now resolve vcpkg as:
VCPKG_ROOT env var (external checkout) > bundled submodule. Docs updated
to describe the new one-time setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:42:42 +01:00
TalonandClaude Sonnet 5 bba605401d chore: comment cleanup pass ahead of open-sourcing
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
Removes leftover debug scaffolding (stray Console.WriteLine/NSLog traces,
dead nick_buf_ptr, a no-op --print-config flag now implemented for real),
fixes stale/misleading comments (channel passwords are no longer a "future
M5+" feature, a wrong cross-reference, a stale TlsContext::close() mention,
an incomplete BanRecord::subject_type doc, and a smoke test pointing at a
build/m1-dev preset that no longer exists), strips internal M1-M5 milestone
jargon from comments now that the roadmap is done, trims comments that just
restated the following line, and consolidates a few "why" explanations that
were duplicated 2-3 times in the same file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:20:18 +01:00
Talon bda37ec27b fix(ios): add AppIcon asset catalog and fix launch screen for TestFlight
Adds Assets.xcassets with a placeholder 1024x1024 AppIcon so Xcode
compiles CFBundleIconName and the required icon sizes into the bundle.
Replaces UILaunchStoryboardName (missing storyboard) with UILaunchScreen
dict, valid for iOS 14+ (min target is iOS 18). Fixes all four App Store
Connect validation errors blocking TestFlight upload.
2026-06-30 13:22:14 +01:00
Talon 9612b0af89 chore(ios): update bundle ID to me.iamtalon.voicecat for App Store
Changes main app bundle ID from cat.voice.VoiceCatiOS, broadcast extension
from cat.voice.VoiceCatiOS.broadcast, and App Group from group.cat.voice.VoiceCat
to match the registered App Store identifier.
2026-06-30 11:48:02 +01:00
TalonandClaude Sonnet 4.6 2c8178fa02 chore: remove skeleton build mode and stub #ifdef scaffolding
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
Drop the M0 no-deps skeleton preset and all VOICECAT_HAS_NET/AUDIO/OPUS/NS
guards that it required. Every subsystem is fully implemented; the stub
#else paths were dead code that added noise to every header and source file.

- CMakePresets.json: remove skeleton configure/build/test entries
- CMakeLists.txt (root/core/tests): remove VOICECAT_USE_VCPKG_DEPS option
  and guards; all targets now build unconditionally
- 17 C++ source files: unwrap HAS_* guards, delete stub #else blocks
- apm_processor.cpp: delete ApmPassthrough no-op class; create() always
  returns RnnoiseProcessor
- 18 test files: remove HAS_* guards and stub int main() skip bodies
- docs/building.md: remove skeleton from preset table and prose

VOICECAT_HAS_LOOPBACK (Windows WASAPI loopback platform gate) unchanged.
29/29 ctest green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 11:32:22 +01:00
Talon 9a20953c08 fix(ios): break AirPods-disconnect reinitialize loop on A2DP presets
Recovery from the 99937c9 audio-device-change commit broadened the
route-change recovery set to "everything except categoryChange /
routeConfigurationChange", which added .override. But .override is
fired by our own applyA2dpSpeakerFallback() -> overrideOutputAudioPort,
which recoverAudio() calls on every recovery. On an A2DP preset
(Stereo Mic / Mono Mic), disconnecting AirPods ping-ponged:

  oldDeviceUnavailable -> recoverAudio -> applyA2dpSpeakerFallback
  -> overrideOutputAudioPort(.speaker) -> .override routeChange
  -> recoverAudio -> applyConfiguration (setCategory resets override)
  -> applyA2dpSpeakerFallback -> overrideOutputAudioPort -> .override -> ...

Each iteration also rebuilt the AVAudioEngine via reconfigure() ->
rebuild() -- the audible reinitialize loop + CPU spin. Voice Chat and
Built-in Mic + Speaker were unaffected (applyA2dpSpeakerFallback
early-returns for non-A2DP modes, so no overrideOutputAudioPort call).

Two-part fix (pure Swift iOS-app target; no C ABI / proto / docs changes):
1. AudioSessionManager.handleRouteChange: added .override to the skip
   list alongside .categoryChange / .routeConfigurationChange. .override
   is only ever fired by our own overrideOutputAudioPort call, so
   treating it as a recovery reason is the loop by definition. The
   AVAudioEngineConfigurationChange observer in IOSVoiceProcessingEngine
   remains as the backstop if an override ever actually stops the engine.
2. IOSAudioRouter.applyA2dpSpeakerFallback: made idempotent via a
   lastAppliedOutputOverride tracker that skips the redundant
   overrideOutputAudioPort call when the desired state (.none for
   external output present, .speaker otherwise) already matches. Reset
   to nil at the top of applyConfiguration() (setCategory can reset the
   override) and on a failed call. Defense-in-depth on top of fix 1.

Build: xcodebuild -project clients/apple/iOS/VoiceCatiOS.xcodeproj
-scheme VoiceCatiOS -destination 'generic/platform=iOS' build green
(Xcode 26.5 / iOS 18.0).
2026-06-25 16:18:21 +02:00
Talon 99937c9446 feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.

Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
  success, AppState.handleConnectEvent no longer sees live-session events.
  Added a weak SessionState.appState; SessionState.handleEvent .disconnected
  calls appState.onLiveSessionDisconnected after the cue -- the single path
  AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
  snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
  thread join via vc_client_destroy), resets the backoff, and arms
  scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
  success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
  <-> cellular interface change or path .unsatisfied it calls
  proactiveReconnect: tearing the session down BEFORE the C core notices the
  dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
  tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
  While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
  and cancel all reconnect state (task + monitor + lastSession + connectedServer).

Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
  .newDeviceAvailable route-change guard; fires on every externally-initiated
  route change reason except the ones we cause ourselves (.categoryChange/
  .routeConfigurationChange) to avoid a notification loop. Interruption-end
  now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
  self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
  engine.start() failure (iOS sometimes refuses until the session is
  re-reactivated -- the silent-death case).

No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
Talon 44a336cc89 fix(ios): cast audio complexity UInt32 to Int in ChannelEditView 2026-06-24 16:49:37 +02:00
Talon 6fe7bf0158 feat: fix voice join/leave, channel edit defaults, channel-update stream restart
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
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
TalonandClaude Opus 4.8 2baefddbe4 feat(windows): system-wide push-to-talk via Raw Input
PTT was focus-scoped (WinForms KeyDown/KeyUp) so it died the moment the
window lost focus. Add an optional system-wide path using the Raw Input
API (RegisterRawInputDevices + WM_INPUT with RIDEV_INPUTSINK) instead of
a WH_KEYBOARD_LL low-level hook -- the latter is the keylogger pattern AV
heuristics flag, which is worse for our unsigned MinGW binary. Raw Input
involves no DLL injection or global hook and passes keys through.

- New VoiceCat.App/Native/RawInput.cs: P/Invoke + structs; register the
  keyboard sink, decode WM_INPUT to vkey/up-down, GetAsyncKeyState helper.
- MainForm overrides OnHandleCreated/OnHandleDestroyed/WndProc to manage
  the sink and route WM_INPUT to PTT; gates the focus-scoped KeyDown/KeyUp
  off when system-wide is on; makes the Deactivate force-release
  conditional; adds a GetAsyncKeyState watchdog on the pump timer so a
  missed key-up (RDP/lock-screen) cannot leave PTT stuck.
- VoiceSettings.SystemWidePtt (default ON) + system-wide checkbox in the
  Audio settings PTT section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:14:01 +02:00
TalonandClaude Opus 4.8 65736df464 fix(windows): polish hotkeys, user list, titlebar, and PM window
- Suppress the system ding on global hotkeys/PTT and the user-list Enter
  key by setting SuppressKeyPress (Handled alone leaves WM_CHAR to beep).
- Preserve the user-list keyboard selection across talking/mute refreshes
  instead of resetting it on every Items.Clear().
- Include the connected server name in the main window titlebar.
- Close the private-message window on Escape.
- Show the PM window without an owner so focus is no longer trapped to it
  and the main window can be worked in while a PM is open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:30:48 +02:00
TalonandClaude Opus 4.8 8bb2ba933c feat(clients): unify volume boost cap to 4x across all clients
Mic input gain (was 3x) and per-user receive gain (was 2x) had
asymmetric boost ceilings. Raise both, plus the desktop aux input
gain (was 3x), to a uniform 4x (400%) on macOS, iOS, and Windows.

The master Output volume slider is unchanged (still 1x). No core
changes needed: the C ABI only clamps negatives, so the ceilings
live entirely in the client UI sliders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:18:10 +02:00
TalonandClaude Opus 4.8 7ef560ba8a fix(windows): statically link MinGW runtime into all binaries
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
The static-MinGW recipe (-static-libgcc -static-libstdc++ -static) was
only applied inside the VOICECAT_BUILD_SHARED branch in core/CMakeLists,
so it covered voicecat.dll but not voicecat-server.exe / vccli. With the
x64-mingw-static triplet only vcpkg's own deps are static; the GCC/MinGW
runtime stays dynamic, so the server failed to start on clean Windows
machines with missing libgcc_s_seh-1.dll / libwinpthread-1.dll /
libstdc++-6.dll.

Move the recipe to a global add_link_options (WIN32 AND MINGW) so every
produced binary embeds the runtime. Verified with objdump -p: only
Windows system DLLs remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:28:29 +02:00
TalonandClaude Opus 4.8 47124b15a2 fix(windows): list all user-session apps in the app-audio picker
The picker gated its list on visible-window processes plus audio sessions
on only the default render device. That both showed non-audio apps (any
window) and missed real ones (windowless or routed to a secondary device).
Process loopback targets a PID and its child tree regardless of whether the
app is currently playing, so the gate fought the capture layer.

Now enumerate every process in the user's interactive session (windowed or
not), deduped by executable with the windowed tree-root as the capture PID;
scan all active render endpoints to flag currently-playing apps with a > and
sort them first. Adds a filter box and persists checks across filtering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:24:20 +02:00
TalonandClaude Sonnet 4.6 04bdb70d47 fix(codec): scale DRED duration to frame size instead of hardcoding 2
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
OPUS_SET_DRED_DURATION was hardcoded to 2 (20 ms), meaning DRED only
covered 1/3 of a lost 60 ms frame and was useless above 20 ms channels.
Now computed as max(2, ceil(frame_ms/10)) so DRED always embeds enough
redundancy to reconstruct one full previous frame regardless of frame size.
The floor of 2 preserves two-frame burst-loss coverage at 10 ms channels.
Decoder side and server are unaffected (server relays payloads verbatim).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 23:44:00 +02:00
TalonandClaude Opus 4.8 b44a200b95 fix(audio): apply receive-side NR to stereo mic streams
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
The per-listener noise-reduction toggle (vc_set_remote_stream) did nothing
on Windows/macOS/iOS. The decode loop gated the RNNoise pass on
dec_channels == 1 as a proxy for "this stream is voice" (assuming
stereo => screen-share). The stereo-mic capture commit broke that: a stereo
mic with send-side NR off transmits stereo Opus, so the receiver decoded
two channels and skipped NR entirely. gain/mute have no channel guard, which
is why only NR appeared broken.

Thread the stream kind through init_recv_stream into RemoteStream::is_voice
(set from si.kind() == STREAM_MIC), gate receive NR on is_voice instead of
channel count, and fold a stereo voice frame to mono -> denoise -> duplicate
back across both channels in place (symmetric with the send-side downmix;
RNNoise is mono-only). Screen-audio shares are never denoised.

New test test_recv_noise_reduction drives AudioEngine and asserts a stereo
voice stream's noise floor collapses with NR on (RMS 1046 -> 0.1) while a
screen-audio share stays unchanged. ctest --preset dev green 29/29.
Docs: voice.md section 10. Shared-core fix; clients need only a rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:11:03 +02:00
TalonandClaude Opus 4.8 f72219ddf3 feat(audio): stereo mic capture on Windows & macOS desktop clients
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
Both desktop mics were hard-mono: the core defaults capture_channels=1 and
neither client ever called vc_set_capture_channels (only iOS did). Add a
persisted "Stereo microphone" toggle to each client's Audio settings, applied
when the mic stream starts and live via vc_set_capture_channels + vc_audio_restart.
Expose both ABI calls in the Windows interop; the macOS wrapper already had them.

Core fix: encode_and_send_frame now folds a stereo mic frame to mono on a mono
channel - previously the channels==2 branch encoded interleaved L/R directly even
on a mono channel, feeding a mono opus_encode 2x its samples (wrong pitch/garbage).
Real stereo still only reaches the wire on a stereo channel; on a mono channel the
mic is cleanly downmixed.

Test: test_stereo_mic_mono_channel. ctest --preset dev green (28/28). Docs: voice.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:48:26 +02:00
Talon b14cf2a4e8 fix(ios): stop core opening a second mic device — dual capture/crackle
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
On iOS the remote end heard the mic twice and crackly (BT headset + internal
mic in Voice Chat; mono + stereo copies of the internal mic in Stereo Mic).

Root cause is an io-thread ordering race. `ensure_audio_running()` only set
`external_capture` when a MIC stream already existed, but it also runs from
`sync_remote_streams` on the post-auth `ServerStateSnapshot` — before the user
joins voice. With `external_playback_` still false and no MIC stream,
`AudioEngine::start()` opened a real miniaudio capture device that stayed open
all session (later calls early-return on running()), racing the AVAudioEngine
input tap fed via `vc_stream_feed_pcm`. `on_capture_frame` then encoded+sent
both paths — the mic transmitted twice, the two unsynchronized capture clocks
producing the crackle.

- core (ensure_audio_running): force `external_capture = true` whenever
  `external_playback_` is set, so iOS unified mode never opens a hardware
  capture device. No-op on desktop.
- ios (AppState): move `setExternalPlayback(true)` before `connect()`, so the
  flag is set before the io thread processes any message — closing the race.

Verified on device: remote end hears the iOS mic once and clean in both Voice
Chat (+ BT) and Stereo Mic.
2026-06-23 17:47:49 +02:00
Talon 19c2fb6ec9 fix(ios): pace mic feed with a prebuffer cushion to stop flutter/crackle
The iOS mic was unusable — a consistent ~40-60ms flutter + volume fade
('slow fan') on every preset. The core sends each captured frame
synchronously (no send pacer), so packet cadence == capture cadence, and
the receiver's playout keeps near-zero buffering by design (its jitter
estimate keys off the regular sender timestamp, so it's blind to arrival
jitter). That's smooth only for a steady sender (desktop miniaudio =
steady 20ms); the iOS AVAudioEngine tap delivers ~2 frames per ~40ms
callback -> bursty -> receiver underruns -> PLC fade.

Fix (iOS-only): the mic tap writes converted 48kHz int16 to an SPSC ring;
a 20ms feed pump drains it and calls feedPcm at a steady cadence. The pump
primes a small prebuffer cushion (3 frames ~60ms, self-healing up to
~120ms on underrun) before releasing, so the tap's bursts can't drain it
to empty. Never reads a partial frame (read consumes what it returns ->
partials were the crackle), and rebuilds with the current channel count
each rebuild() (a frozen count fed mono-as-stereo = octave-up on a
Stereo->Voice Chat switch).

Trade-off: ~60-120ms added mic-send latency, unavoidable when de-bursting
for a near-zero-buffer receiver. PROGRESS.md notes the proper follow-up:
make the jitter buffer measure real RFC-3550 arrival jitter so the
receiver absorbs bursts itself.

Verified: xcodebuild Debug BUILD SUCCEEDED (iOS Simulator, arm64).
2026-06-23 15:40:54 +02:00
Talon cd9c08a47a fix(apple): merge vendored librnnoise.a into xcframework fat static lib
Both VoiceCatMac and VoiceCatiOS failed to link with 'Undefined symbols
for architecture arm64: _rnnoise_create/_rnnoise_destroy/_rnnoise_process_frame'.
Root cause: build-xcframework.sh merged vcpkg deps into the fat static lib but
NOT the locally-built vendored librnnoise.a (a CMake target from
third_party/rnnoise/ linked privately into voicecat via VOICECAT_HAS_NS — not a
vcpkg dep). The xcframework had been rebuilt after the RNNoise commit but still
omitted the symbols, so every slice's libvoicecat-fat.a referenced _rnnoise_*
with no defining object. The iOS slices were also stale (pre-rnnoise) and
absent from the xcframework entirely.

Fix: build-xcframework.sh now also collects .a files from build/<preset>/lib/
(excluding libvoicecat*) so vendored CMake-target static libs like librnnoise.a
get merged in. Future-proof: any new vendored static-lib target landing in
build/<preset>/lib/ is picked up automatically. README 'Fat static library'
section updated.

Verify: rebuilt VoiceCatCore.xcframework --all → all 3 slices (macos-arm64,
ios-arm64, ios-arm64-simulator) now carry the 10 _rnnoise_* symbols; fat lib
~30 MB → ~33 MB. xcodebuild Debug BUILD SUCCEEDED for VoiceCatMac, VoiceCatiOS
(iphonesimulator arm64), and VoiceCatiOS (iphoneos arm64). No core/ABI/proto
changes — xcframework artifact + build script only.
2026-06-23 14:25:30 +02:00
Talon 2e0e0caccb feat(clients): wire RNNoise mic noise reduction into Windows, macOS, and iOS
Expose the existing send-side vc_set_input_noise_reduction C ABI (MIC-only,
mono, LOCAL — denoises captured mic PCM before input gain and VAD/PTT gate)
as a persisted global toggle in each client's audio settings, applied live
and re-applied on Join Voice. Mirrors the existing mic-gain wiring pattern.

- Shared Swift (VoiceCatCore): add setInputNoiseReduction(_:) wrapper
- Windows: P/Invoke + SetInputNoiseReduction wrapper, MicNoiseReduction in
  VoiceSettings, new checkbox in AudioSettingsForm (layout shifted +28px),
  apply on Join Voice; also fix stale 'planned - currently passthrough'
  label on the receive-side per-user NR checkbox (RNNoise now backs it)
- macOS: inputNoiseReduction state + UserDefaults in MainWindowController,
  NR checkbox + nrChanged action in SettingsWindowController
- iOS: inputNoiseReduction in VoiceState + setter + restore in SessionState,
  NR Toggle in SettingsView Voice section

Aux/screen are out of scope by design (core's NR guards kind == MIC). Apple
builds require a rebuilt VoiceCatCore.xcframework with VOICECAT_HAS_NS.
2026-06-23 14:11:18 +02:00
TalonandClaude Opus 4.8 bad9c7533a feat(audio): real noise suppression via vendored RNNoise (send + receive)
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
The two-sided NR plumbing (RemoteStream::recv_ns + the per-listener
vc_set_remote_stream noise_reduction toggle) was wired but inert:
ApmProcessor::create() returned a no-op passthrough, because the
originally-planned webrtc-audio-processing has no working Windows/macOS
build. Drop in RNNoise as the real backend behind the same ApmProcessor
interface, lighting up both NR paths.

- Vendor RNNoise (BSD-3 + CC0) at third_party/rnnoise/ — the vcpkg port
  is !windows !arm, so it can't cover our primary targets. Shrunk int8
  model (78MB -> 11.7MB via upstream scripts/shrink_model.sh), built as a
  standalone C static lib with no RTCD (portable scalar path on x86,
  auto-NEON on arm64) under -DDISABLE_DEBUG_FLOAT. Model is baked in
  (rnnoise_create(NULL)); no runtime file.
- New RnnoiseProcessor (core/src/audio/apm_processor.cpp) selected by
  ApmProcessor::create() when VOICECAT_HAS_NS. Mono/48kHz/480-sample;
  our clock is fixed 48kHz and Opus frame sizes are multiples of 480, so
  no resampling. RT-safe: allocates at construction, lock-free in the
  capture/playback callbacks.
- Receive-side: lit up via the factory; gated to mono streams (a stereo
  stream is a screen-audio share, not voice).
- Send-side (new): vc_set_input_noise_reduction(client, enable) ABI +
  vc_client::mic_ns_, run before input gain/VAD in on_capture_frame. A
  stereo mic is downmixed to mono ONLY when NR is on — with NR off a
  stereo mic keeps full stereo (never collapse mic quality unasked).
- Enable C as a project language for the vendored lib.
- New noise_suppression test: white noise through ApmProcessor::create()
  drops ~99.9% RMS. ctest --preset dev green, 28/28. windows-client DLL
  builds clean with vc_set_input_noise_reduction exported, system-only deps.
- Docs synced: voice.md §10, tech-stack.md §1/§5, third_party/README.md,
  vcpkg.json note, PROGRESS.md, CLAUDE.md.

Client on/off UI toggles (Windows/macOS/iOS) are the remaining follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:30:54 +02:00
TalonandClaude Opus 4.8 7249a8fd30 feat(clients): add aux outgoing stream (mic + second input device) on Windows + macOS
Lets a user transmit a second hardware input device (e.g. line-in / aux)
alongside the mic, with its own device picker and volume, from Audio Settings.

No core/ABI/proto changes: the aux is a VC_STREAM_AUX_DEVICE stream started
with external_feed=1 and fed via vc_stream_feed_pcm (the same external-feed
pipeline screen-audio uses). Per-kind local_streams_ already allows mic +
screen + one aux to coexist; volume is a client-side gain multiply (the core's
vc_set_input_gain is mic-only/global). Aux is always-on (core never gates
AUX_DEVICE on VAD/PTT) and is tied to the voice session.

Windows: new Audio/InputDeviceCapture.cs (WASAPI shared-mode capture from a
real input endpoint + capture-endpoint enumeration); aux section in
AudioSettingsForm.cs; lifecycle in MainForm.cs; persistence in VoiceSettings.cs.

macOS: new Audio/InputDeviceCapture.swift (AVAudioEngine input-node tap pinned
to the chosen Core Audio device + device enumeration by stable UID); aux section
in SettingsWindowController.swift; lifecycle + UserDefaults persistence in
MainWindowController.swift; file registered in project.pbxproj.

Windows verified (C# solution builds clean; aux confirmed working). macOS build
+ E2E pending a Mac.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:56:09 +02:00
TalonandClaude Sonnet 4.6 a48b47d4ca feat(windows): move audio settings to dedicated dialog, fix device ComboBox accessibility
Audio input settings (device picker, VAD/PTT mode, VAD sensitivity, mic gain,
PTT key) move from the always-visible bottom panel into Settings > Audio...,
matching the macOS/iOS pattern.

The device ComboBox now uses DataSource + DisplayMember="Name" instead of
Items.Add() with no DisplayMember — this fixes both the display bug (was
showing the full DeviceInfo record ToString()) and the NVDA silence on
dropdown open (DataSource binding exposes proper MSAA text per item).

Changes apply live for immediate feedback; Cancel reverts. VoiceSettings
gains InputDeviceId to persist the chosen device across sessions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 12:15:18 +02:00
Talon 95f1fb70b0 feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
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
Talon d30c4ee2f5 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
TalonandClaude Opus 4.8 7547b8e140 fix(audio): wire in-band FEC into the decoder loss path
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
The encoder set OPUS_SET_INBAND_FEC, but the decoder never invoked FEC --
the loss path went DRED -> PLC, so FEC redundancy was emitted (and paid for
in bitrate) yet never consumed.

Wire FEC recovery into AudioEngine::on_playback between DRED and PLC: copy
the next buffered packet once, try DRED, else (if the stream negotiated FEC)
decode(next_pkt, ..., fec=true), else PLC. Recovery priority is now
DRED -> FEC -> PLC. Add per-stream RemoteStream::fec_enabled_, captured from
OpusParams in init_recv_stream. Docs (voice.md) updated to match.

ctest --preset dev: 27/27 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:18:29 +02:00
TalonandClaude Opus 4.8 ce2035f271 fix(audio): bound playout depth to stop voice latency ratcheting up
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
Latency between speakers grew to multiple seconds and only reset on
rejoining voice. Root cause was the receiver playout logic, not the
codec settings: the playout clock free-ran in real time while the
sender omitted silence from its timestamps (and set no header flags),
and the only correction snapped the clock to the *oldest* buffered
frame — which could only ever add standing latency. target_depth_ms_
was computed but never enforced, so latency could only grow or reset.

Fix: bound playout against the stream's leading edge (newest frame).
(Re)seed to the leading edge on start/marker/starve (no prebuffer, so
latency stays low), and frame-skip catch-up trims any backlog beyond
target+hysteresis — the missing downward force.

Hardening: sender now stamps kFlagMarker (talkspurt start) and kFlagDtx,
consumed on recv for clean resync; adaptive late-drop window; EWMA
outlier rejection so silence gaps/stragglers don't poison the estimate;
duplicate counting and ring-underrun diagnostics.

New test_jitter_depth asserts depth stays bounded (<200ms) while
arrivals outrun playback. ctest --preset dev green (27/27).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:02:20 +02:00
TalonandClaude Opus 4.8 e155e342f4 feat(audio): channel sample_rate caps Opus bandwidth (narrowband/wideband)
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
The per-channel sample_rate field was inert after pinning the codec to
48 kHz. Make it meaningful without changing the 48 kHz clock: carry it as
OpusParams::max_bandwidth_hz and apply OPUS_SET_MAX_BANDWIDTH in
OpusEncoder::init (8000->narrowband, 16000->wideband, 24000->super-wideband,
48000->full). A low-bitrate room can now shed out-of-band content while
every endpoint keeps a single 48 kHz clock.

Make sample_rate channel-authoritative on the server: conn_session no
longer overrides effective sample_rate with the client's always-48000
request (it now behaves like frame_ms/mode). vc_get_stream_audio_config
reports the channel's configured rate for own streams too.

New ctest channel_samplerate: a 7 kHz tone is attenuated ~1000x on an
8 kHz (narrowband) channel vs a 48 kHz (full-band) channel, proving the cap
is in effect. ctest --preset dev 26/26.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:56:49 +02:00
TalonandClaude Opus 4.8 a460009a2f fix(audio): reframe send path to channel frame_ms; pin codec to 48 kHz
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
The AudioEngine capture clock is fixed at 48 kHz / 20 ms (960-sample
frames), but a channel may set any Opus frame_ms (2.5..60 ms, voice.md
§3) and the server enforces it unclamped. on_capture_frame handed the
engine's 960-sample frame straight to an encoder configured for the
channel's window: frame_ms > 20 was silently ignored, and frame_ms < 20
broke entirely (receiver sized its decode buffer too small ->
OPUS_BUFFER_TOO_SMALL -> dead audio). Affected the hardware mic and
vc_stream_feed_pcm alike.

Reframe each captured/fed block to ls.frame_samples via a per-LocalStream
accumulator (pre-sized at announce, no RT-thread alloc) before
encode_and_send_frame; the 20 ms case stays a zero-copy fast path. Also
pin the codec to 48 kHz in opus_params_from_audio_config — it was honoring
a non-48k effective sample_rate against a 48 kHz PCM clock.

New ctest frame_ms_reframe covers 40 ms (accumulate) and 10 ms (split)
feed->encode->relay->decode->sink round trips. ctest --preset dev 25/25.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:45:02 +02:00
TalonandClaude Opus 4.8 50416c33a2 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:31 +02:00
TalonandClaude Opus 4.8 725bd8e925 fix(windows): screen-reader accessibility for context menus and channel tree
- ContextMenuStrip: select first item on Opened so keyboard-invoked menus
  (Shift+F10 / Apps key) raise the UIA focus event immediately instead of
  staying silent until the first arrow key.
- Name the SplitContainer/SplitterPanel containers so screen readers announce
  orientation instead of a stack of anonymous pane nodes.
- Take the resize splitters out of the Tab cycle (TabStop = false) so focus
  moves control-to-control.
- RefreshChannelTree: restore keyboard focus and re-announce the current node
  after a Nodes.Clear()/rebuild, so the tree no longer loses focus when
  channels/users change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 13:34:26 +02:00
TalonandClaude Opus 4.8 483f889910 fix(server): bind UDP media to the TCP port so self-host needs one forward rule
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
media_port defaulted to 0 (OS-assigned) and --port only set the TCP bind_port,
so the UDP relay bound a random high port and advertised it to clients in HELLO.
Self-hosters forwarding only 8384/udp saw connect-OK-but-no-voice, contradicting
docs/deployment.md (control and media share one port). Media now follows
bind_port when media_port is unset; 0=OS-assigned survives when bind_port is also
0 so ephemeral-port tests are unaffected. Banner now reads TCP :8384 UDP :8384.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:48:13 +02:00
TalonandClaude Opus 4.8 5e18dfa1c9 fix(windows): real native exclude + self-echo removal for screen audio
"All apps except selected" previously captured the complement of a frozen
app snapshot in INCLUDE mode (missed late-launched apps and system sounds,
wasted captures on silent windows). It now opens a single ProcessLoopbackCapture
in EXCLUDE mode (AUDIOCLIENT_PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE)
of the one chosen app — true system-mix-minus-one, dynamic so apps launched
after sharing starts are included. The picker enforces single-selection in
exclude mode (the activation params take one target PID).

Adds an "Exclude VoiceCat's own audio (prevents echo)" checkbox (default on,
entire-desktop only) that routes the desktop capture through the same EXCLUDE
path targeting our own process id, killing the whole-device self-echo loop.

No C++/ABI changes. Updates voice.md and PROGRESS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:28:53 +02:00
Talon fb73b694d0 fix(ios): VPIO silent playback + quiet speaker in stereo/studio presets
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
Two on-device bugs in the native iOS Voice-Processing path (Swift-only;
no core/ABI change).

1. Voice Chat (VPIO) silent playback: doStartMicStream() called
   audioRestart() BEFORE startStream, so when the engine was already
   running (a remote stream had started it) it reopened with
   external_capture=false and opened a hardware miniaudio capture device.
   The announce-result restart then early-returns (engine already running)
   so that device was never dropped and fought the AVAudioEngine VPIO unit,
   silencing playback. Now: setExternalPlayback first, then startStream
   (stores external_feed synchronously), THEN audioRestart() — the core
   reopens in full external mode (no hardware devices). Added VPIO
   diagnostics: graph/route formats at start, ring written/read totals at
   teardown.

2. Stereo Mic / Studio quiet earpiece: the .builtInMicBtA2dp presets omit
   .defaultToSpeaker (it breaks A2DP) and skip forceSpeaker, so with no
   Bluetooth connected output pinned to the quiet receiver. New
   IOSAudioRouter.applyA2dpSpeakerFallback() overrides to the built-in
   speaker when no external (A2DP/wired/AirPlay) output is present and
   clears the override when one is — called after activation and on
   device-change route changes.
2026-06-22 03:43:00 +02:00
Talon 6c17881cc0 feat(ios): real echo cancellation/NR via native Voice-Processing engine
iOS "voice chat" had echo and no noise suppression: real iOS AEC/NS/AGC
come only from Apple's Voice-Processing I/O unit (VPIO), but the core
plays/captures via miniaudio's plain RemoteIO units, so .voiceChat mode
alone never engaged AEC.

Core (ABI PATCH 1->2):
- vc_set_mixed_output_sink + vc_set_external_playback. In external mode the
  AudioEngine opens no hardware playback device; a mixer-timer thread drives
  on_playback (decode+mix) on a ~20ms cadence and ships the final mix to the
  sink. start() also skips the hardware capture device when the MIC stream is
  external_feed (AudioParams.external_capture).
- New white-box test test_external_playback (drives the timer with no hw).

iOS/Swift:
- StreamDescriptor.externalFeed; VoiceCatClient.setMixedOutputSink /
  setExternalPlayback wrappers.
- IOSVoiceProcessingEngine: AVAudioEngine + setVoiceProcessingEnabled; mic
  tap -> feedPcm, mixed-sink lock-free ring -> AVAudioSourceNode (both share
  the VPIO unit so AEC has its reference signal).
- IOSAudioRouter.currentConfigUsesVoiceProcessing scopes VPIO to the AEC
  presets; SessionState join/leave + reconcileVoicePath() switch paths;
  Voice Chat defaults to speaker; Settings surfaces AEC/NS state.

Known: pending on-device verification; a few bugs to fix afterward.
2026-06-22 02:38:01 +02:00
TalonandClaude Opus 4.8 e806b698ec feat(windows): per-app screen-audio sharing (include/exclude)
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
Adds "only selected apps" and "all apps except selected" screen-audio
modes to the Windows client, alongside the existing entire-desktop path.

Per-app capture uses WASAPI process loopback (AUDCLNT_ACTIVATIONTYPE_
PROCESS_LOOPBACK) via ProcessLoopbackCapture, mixed by ProcessAudioMixer
and fed to the core through vc_stream_feed_pcm (external_feed=1 so the
core skips its own loopback device).

Init must pass AUDCLNT_STREAMFLAGS_LOOPBACK | EVENTCALLBACK |
AUTOCONVERTPCM; the LOOPBACK flag is what makes the virtual endpoint
deliver rendered audio (without it every buffer is flagged SILENT) and
AUTOCONVERTPCM resamples the app's native format to 48k s16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:19:38 +02:00
TalonandClaude Sonnet 4.6 4f89d2d32d feat(deploy): Docker + Linux server deployment
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
Multi-stage Dockerfile (builder → export → runtime) producing a 149 MB
Ubuntu 24.04 image, verified booting end-to-end on Docker Desktop.  vcpkg
fetched via shallow git fetch at the pinned baseline, release-only overlay
triplets (x64-linux, arm64-linux) to halve intermediate disk usage, and
buildtrees deleted within the RUN layer so they never land in the image or
the BuildKit cache.  Binary cache mount (VCPKG_BINARY_SOURCES) makes
subsequent rebuilds restore pre-built packages instead of recompiling.

Also adds:
- docker-compose.yml for one-command local deploy
- .dockerignore (excludes clients/, build/, .git/)
- .github/workflows/build-linux.yml — CI cross-build for amd64 + arm64
  with downloadable artifacts (primary path for building from Windows)
- scripts/build-linux-binaries.sh — local Docker binary extraction fallback
- deploy/linux/voicecat.service — hardened systemd unit for bare-metal
- cmake/voicecat-toolchain.cmake now auto-wires VCPKG_OVERLAY_TRIPLETS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 19:51:43 +02:00
Talon 6071c8e238 fix(media): stop permanent voice loss after bad-network blip (protocol v2)
A bad UDP packet on a flaky link could permanently wedge the voice path,
unrecoverable even across app restarts. Three defects:

1. Anti-replay window was advanced from the UNAUTHENTICATED header seq
   before the AEAD tag was checked, and not rolled back on failure. One
   corrupted/forged frame shoved recv_highest_ far ahead, after which every
   legitimate frame was rejected as "too old" forever. Reorder to
   replay-check -> authenticate -> update (RFC 3711 3.3); the window now
   moves only after a successful tag check.

2. The wire seq was only the low 16 bits of the nonce counter (zero-extended
   on receive). After 65,536 frames the nonce desynced and all frames failed
   auth. Widen the voice frame seq u16 -> u64 (header 14 -> 20 bytes). The
   core owns all UDP framing, so Swift/C# clients need only a rebuild. This
   is a versioned wire change: VOICECAT_PROTOCOL_VERSION 1 -> 2, handshake
   rejects on mismatch.

3. Server leaked per-session UDP state on disconnect; unregister_session now
   frees udp_endpoints_/udp_tokens_/ssrc_to_session_.

Also add rate-limited dropped-frame logging to MediaRelay so a wedged media
path is observable. New regression tests in test_media_aead.cpp cover the
poison (fails on old code) and the 16-bit wrap. ctest --preset dev
-E external_pcm: 22/22 pass (external_pcm aborts on a pre-existing CoreAudio
shutdown race, unrelated).
2026-06-21 17:45:28 +02:00
Talon 5be6d8430d feat(ios): user-toggleable speakerphone output
Add a "speaker output" override so users can route audio to the built-in
speaker instead of the earpiece when no headphones/Bluetooth are connected.
Previously the receiver was the only fallback on the default Voice Chat preset.

The toggle inserts .defaultToSpeaker into the AVAudioSession category options
(skipped for the A2DP mode, where it would break Bluetooth routing). It yields
to connected BT/wired output and is orthogonal to preset matching. Exposed both
as a call-bar button in VoiceControlsView and a persisted Settings toggle.
2026-06-21 15:47:20 +02:00
Talon 0c6b1a36cf feat(ios): ad-hoc distribution scripts for pre-TestFlight testing
Add scripts/dist-ios-adhoc.sh and scripts/asc_api.py to build an ad-hoc
signed IPA and the OTA web-install files (manifest.plist + index.html)
for sharing the iOS client with registered devices before TestFlight.

- asc_api.py: App Store Connect API helper (ES256 JWT via cryptography,
  urllib) to register device UDIDs and list registered devices.
- dist-ios-adhoc.sh: registers UDIDs, builds the iOS device xcframework
  slice, archives + exports with method=release-testing using
  -allowProvisioningUpdates, and stages the install files into
  dist/ios-adhoc/.
- Document the workflow in clients/apple/README.md; ignore __pycache__.
2026-06-21 15:28:26 +02:00
Talon c1e6f4f7ff feat(macos): per-app audio selection for screen sharing
Let users choose what the SCREEN_AUDIO stream captures before sharing:
share everything, only selected apps, or all except selected apps, plus a
first-class "Exclude screen reader (VoiceOver) audio" toggle.

ScreenCaptureKit filters audio per application, so ScreenAudioCapture now
takes a ScreenAudioSelection and builds the matching SCContentFilter
(including:/excludingApplications:). New ScreenSharePickerSheet lists
running apps from SCShareableContent. iOS left untouched -- ReplayKit only
delivers the mixed system stream, so per-app filtering is impossible there.
2026-06-21 13:35:01 +02:00
Talon 6b7f06a282 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
461 changed files with 400479 additions and 34764 deletions
-15
View File
@@ -1,15 +0,0 @@
# VoiceCat C++ style. Keep formatting boring and consistent.
BasedOnStyle: Google
Language: Cpp
Standard: c++20
ColumnLimit: 100
IndentWidth: 4
TabWidth: 4
UseTab: Never
AccessModifierOffset: -2
PointerAlignment: Left
DerivePointerAlignment: false
AllowShortFunctionsOnASingleLine: Inline
AllowShortIfStatementsOnASingleLine: false
SortIncludes: CaseInsensitive
IncludeBlocks: Regroup
+26
View File
@@ -0,0 +1,26 @@
# Git history — large and never needed inside the build context
.git/
# Previous build outputs
build/
artifacts/
**/bin/
**/obj/
# Native GUI client code (Swift/Xcode, C#/WinForms) — server build doesn't need these
clients/
# Documentation and prose — not compiled
docs/
*.md
AGENTS.md
PROGRESS.md
CLAUDE.md
# Editor / tooling config
.vscode/
.idea/
# OS noise
.DS_Store
Thumbs.db
+7
View File
@@ -0,0 +1,7 @@
root = true
[*.cs]
indent_style = space
indent_size = 4
csharp_style_namespace_declarations = file_scoped:warning
dotnet_sort_system_directives_first = true
+52
View File
@@ -0,0 +1,52 @@
name: Build and test
on:
push:
paths: ['src/**', 'tests/**', 'clients/**', 'native/**', 'proto/**', 'assets/**', 'scripts/**', '*.slnx', '*.props', '*.targets', 'global.json', 'Dockerfile', '.github/workflows/dotnet.yml']
pull_request:
paths: ['src/**', 'tests/**', 'clients/**', 'native/**', 'proto/**', 'assets/**', 'scripts/**', '*.slnx', '*.props', '*.targets', 'global.json', 'Dockerfile', '.github/workflows/dotnet.yml']
workflow_dispatch:
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-24.04, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: Build and stage native codec/DSP
shell: pwsh
run: ./scripts/build-native.ps1
- run: dotnet restore VoiceCat.slnx --locked-mode
- run: dotnet build VoiceCat.slnx -c Release --no-restore
- run: dotnet test VoiceCat.slnx -c Release --no-build
- shell: pwsh
run: ./scripts/check-licenses.ps1
apple-client:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: Install Apple workloads
run: dotnet workload install macos ios --version 10.0.401
- name: Build and stage native codec/DSP
shell: pwsh
run: ./scripts/build-native.ps1
- name: Build and stage static iOS codec/DSP
run: ./scripts/build-native-ios.sh
- name: Restore managed Apple clients
run: dotnet restore clients/apple/VoiceCat.Apple.slnx
- name: Build managed AppKit and UIKit clients
run: dotnet build clients/apple/VoiceCat.Apple.slnx -c Debug --no-restore
+10 -8
View File
@@ -1,4 +1,8 @@
# Build output # Build output
**/bin/
**/obj/
**/TestResults/
/artifacts/
/build/ /build/
/out/ /out/
@@ -14,9 +18,7 @@
*.exe *.exe
*.pdb *.pdb
# vcpkg # vcpkg (bundled as a submodule at /vcpkg — see docs/building.md §2)
/vcpkg_installed/
/vcpkg/
# Generated protobuf # Generated protobuf
*.pb.cc *.pb.cc
@@ -36,18 +38,18 @@
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Apple / Windows client build artifacts (added in M4) # Apple / Windows client build artifacts
clients/apple/**/build/ clients/apple/**/build/
clients/apple/**/bin/
clients/apple/**/obj/
clients/apple/**/*.xcodeproj/xcuserdata/ clients/apple/**/*.xcodeproj/xcuserdata/
clients/apple/**/*.xcodeproj/project.xcworkspace/ clients/apple/**/*.xcodeproj/project.xcworkspace/
clients/apple/**/*.xcframework/ clients/apple/**/*.xcframework/
clients/windows/**/bin/ clients/windows/**/bin/
clients/windows/**/obj/ clients/windows/**/obj/
# SwiftPM build artifacts # SwiftPM build artifacts
clients/apple/.build/
clients/apple/.swiftpm/
clients/apple/Package.resolved
# Test artifacts: TOFU pin store written by vc_client during headless tests # Test artifacts: TOFU pin store written by vc_client during headless tests
# (core/src/core/client.cpp falls back to this relative path when tofu_store_path is unset).
voicecat_tofu_pins.txt voicecat_tofu_pins.txt
# Python bytecode cache (e.g. scripts/asc_api.py)
+38 -95
View File
@@ -1,109 +1,52 @@
# AGENTS.md — working method # VoiceCat working method
This file is the **working method** for a developer or AI agent picking up VoiceCat. Companion Start with `CLAUDE.md` for commands and architecture and `PROGRESS.md` for the current handoff.
files: The supported product is the .NET 10 implementation.
- [`CLAUDE.md`](CLAUDE.md) — the hub: build/test commands, architecture at a glance, doc map. ## Definition of done
- [`PROGRESS.md`](PROGRESS.md) — living tracker: what's done, what's next. **Update it as you work.**
- [`docs/`](docs/) — the **source of truth** for all design.
Read those, then use the method below. A clean compile is the floor. Changes are done when their observable behavior is covered by a
test or, for hardware/accessibility/release behavior, by a clearly recorded manual gate.
## What this repo is right now For implementation work:
A complete **design** ([`docs/`](docs/)) plus a working implementation through M5: real TLS 1. Read the relevant code and current contract document.
control plane, encrypted UDP voice (Opus), multi-stream, TOFU identity pinning, channel tree, 2. Add or adjust the smallest behavior test that demonstrates the requested result.
permissions, moderation, disconnect/keepalive/reaper. The Windows WinForms C# client is 3. Implement the change without weakening encryption, real-time safety, or accessibility.
shipped (M4). The macOS/iOS Swift client is next. The `skeleton` preset still links a 4. Build and run the affected tests, then the full managed test suite when practical.
no-deps stub path (`VC_ERR_NOT_IMPLEMENTED`) for smoke-check builds. 5. Update concise current documentation and `PROGRESS.md` if the handoff changed.
## The working method (important) ## Supported layout
**A clean compile is the floor, not the goal.** Do not treat "make the compiler errors go | Path | Purpose |
away" as done. Each milestone in [`docs/roadmap.md`](docs/roadmap.md) has an **exit |---|---|
criterion stated as observable behavior** — that is what "done" means. Examples: | `proto/voicecat.proto` | Control-plane wire schema |
| `src/` | Managed server, core, protocol, crypto, audio, codec/DSP, and CLI |
| `tests/VoiceCat.Tests/` | Managed behavior tests |
| `clients/windows/` | Supported WinForms client |
| `clients/apple/` | Supported AppKit and UIKit clients |
| `native/media/` | Required narrow Opus/RNNoise C ABI |
| `native/rnnoise/` | Vendored RNNoise source/model |
| `native/apple/broadcast/` | Required ReplayKit extension and shared-memory producer |
- M1 done = *two `vccli` instances actually chat through a real server over TLS*, not "it builds". ## Core verification
- M2 done = *you can talk between two clients in a channel and hear loss concealment work*.
So the loop is:
1. Pick the current milestone in `docs/roadmap.md`. Read the relevant design doc section.
2. Write the smallest test (CTest, or a `vccli` interaction) that encodes the exit behavior.
3. Implement the subsystem until that test passes — not just until it compiles.
4. Keep the build green and the existing tests passing on every commit.
Every commit must compile and pass `ctest`. Behavior tests are how you know you're actually
making progress.
## Build
Default development preset (real deps via vcpkg — works on Windows/Linux/macOS):
```bash ```bash
export VCPKG_ROOT=/path/to/vcpkg # bootstrap vcpkg first; cross-platform ./scripts/build-native.ps1
cmake --preset dev dotnet restore VoiceCat.slnx --locked-mode
cmake --build --preset dev dotnet build VoiceCat.slnx -c Release --no-restore
ctest --preset dev dotnet test VoiceCat.slnx -c Release --no-build
./scripts/check-licenses.ps1
``` ```
Skeleton (no third-party deps — works immediately, no vcpkg needed): Apple builds additionally use `./scripts/build-native-ios.sh` and
`clients/apple/VoiceCat.Apple.slnx` on macOS.
```bash ## Hard rules
cmake --preset skeleton
cmake --build --preset skeleton
ctest --preset skeleton
```
> **Windows gotcha — always run `ctest` and built binaries via PowerShell, not Git Bash.** - Encryption is mandatory: TLS control plus authenticated encrypted UDP media.
> MinGW-built executables fail in Git Bash with exit code `0xc0000139` - Never introduce GPL/LGPL dependencies.
> (STATUS_ENTRYPOINT_NOT_FOUND) even though the file exists and is marked executable. - Real-time audio callbacks never allocate, lock, block, or perform I/O.
> PowerShell runs them correctly. Use the PowerShell tool (not Bash) for any `ctest`, - Preserve accessible names, keyboard operation, and curated screen-reader announcements.
> `voicecat-server.exe`, or `vccli.exe` invocation on Windows. - Wire, database, and shared-ring changes are deliberate versioned changes.
- Keep `PROGRESS.md` short. Use Git history rather than accumulating completed-work prose.
Other presets: `release` (optimized + tests), `server-release` (optimized + stripped,
deployment-shaped), `windows-client` (DLL for C# app), `apple-dev`/`apple-ios`/
`apple-ios-sim` (Apple platform scaffolding). See [`docs/building.md`](docs/building.md)
for the full matrix.
`vcpkg.json` pins all deps to a fixed vcpkg baseline — `cmake --preset dev` resolves them
automatically on first configure. The vcpkg triplet is auto-resolved from the host platform
by [`cmake/voicecat-toolchain.cmake`](cmake/voicecat-toolchain.cmake).
## Where each subsystem lives (and its doc)
| Path | Subsystem | Design |
|------|-----------|--------|
| `core/include/voicecat.h` | The C ABI every client/server calls | docs/architecture.md §4 |
| `core/proto/voicecat.proto` | Control-plane wire format | docs/protocol.md |
| `core/src/net/` | Asio TCP/UDP transport, framing | docs/architecture.md, docs/protocol.md §1 |
| `core/src/crypto/` | TLS 1.3 (mbedTLS), media AEAD (libsodium), anti-replay | docs/security.md |
| `core/src/codec/` | Opus encode/decode, FEC/DTX | docs/voice.md §34 |
| `core/src/protocol/` | Envelope (de)serialize, state machine, routing | docs/protocol.md |
| `core/src/session/` | Channels, users, streams, permissions, text | docs/protocol.md §5 |
| `core/src/audio/` | Capture/playback (miniaudio), APM DSP, jitter buffer, mixer | docs/voice.md §811 |
| `server/` | Connection mgr, session registry, SFU relay, SQLite | docs/architecture.md §5 |
| `tools/vccli/` | Headless client to drive/verify the protocol | — |
## Suggested first steps (M1 spine)
1. **Wire protobuf + framing** (`net/` + `protocol/`): build, then generate C++ from
`voicecat.proto`, implement the `[u32 length][Envelope]` framing over a plain TCP socket
(TLS can come right after). Test: round-trip an `Envelope` through the framer.
2. **TLS 1.3 via mbedTLS** (`crypto/`): wrap the TCP channel. Test: `vccli` completes a TLS
handshake against `voicecat-server` and exchanges a `ClientHello`/`ServerHello`.
3. **Auth + state** (`session/`): guest + admin-provisioned accounts (Argon2id/SQLite),
channel tree snapshot/deltas, ephemeral text relay. Test: two `vccli` chat.
Then proceed to M2 (UDP media) per the roadmap.
## House rules
- **No GPL/LGPL dependencies, ever** (closed-source redistribution is a goal). See
docs/tech-stack.md §5. CI should fail on a copyleft transitive dep.
- **Encryption is mandatory** — never add a plaintext transport path. docs/security.md.
- **Real-time audio threads** never allocate, lock, or block. docs/architecture.md §3.
- Keep `docs/` and code in sync. If you change a wire format or the C ABI, update the doc in
the same commit.
- The C ABI is the contract for the Swift/C# clients — treat changes to `voicecat.h` and
`voicecat.proto` as deliberate, versioned events (docs/protocol.md §8).
+35 -148
View File
@@ -1,162 +1,49 @@
# CLAUDE.md — agent hub for VoiceCat # VoiceCat developer guide
Auto-loaded each session. This is the **map**: build commands, architecture at a glance, and VoiceCat is a self-hosted encrypted voice and text chat system. The supported implementation
where everything is. For the *working method* read [`AGENTS.md`](AGENTS.md); for *what's done is .NET 10. Read `AGENTS.md` for working rules and `PROGRESS.md` for the current short status.
and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](docs/).
> **One-line status:** M5 (moderation & admin UI) is complete — permissions, kick/ban/move, ## Build and test
> server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows
> WinForms C# client shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj`
> at `clients/apple/macOS/`. **iOS SwiftUI client shipped** — `VoiceCatiOS.xcodeproj` at
> `clients/apple/iOS/`. `ctest --preset dev` green — 23/23 tests.
> External PCM feed/tap API (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) shipped.
> **Screen-audio sharing shipped on macOS (ScreenCaptureKit) and iOS (ReplayKit Broadcast
> Upload Extension → host App Group ring → `vc_stream_feed_pcm`).** See [`PROGRESS.md`](PROGRESS.md).
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control)
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives
native clients (Swift on macOS/iOS, C# on Windows) and the server.
---
## Build & test commands
The default development preset is **`dev`** — it builds everything (server + tools + tests)
with real vcpkg deps. The `skeleton` preset (no deps, stubs only) is a fast smoke check; see
[`docs/building.md`](docs/building.md) for the full preset matrix.
```bash ```bash
# Configure + build (default development preset; needs VCPKG_ROOT) ./scripts/build-native.ps1
cmake --preset dev dotnet restore VoiceCat.slnx --locked-mode
cmake --build --preset dev dotnet build VoiceCat.slnx -c Release --no-restore
dotnet test VoiceCat.slnx -c Release --no-build
# Run the tests (21 behavior tests — grows per milestone) ./scripts/check-licenses.ps1
# NOTE on Windows: run ctest via PowerShell, NOT Git Bash — MinGW binaries fail in Git Bash
# with exit 0xc0000139 (STATUS_ENTRYPOINT_NOT_FOUND). PowerShell runs them correctly.
ctest --preset dev # or: ctest --test-dir build/dev --output-on-failure
# Run the binaries — same Windows rule: use PowerShell, not Git Bash
./build/dev/bin/vccli # headless test client
./build/dev/bin/voicecat-server --help
./build/dev/bin/voicecat-server --name "My Server"
# Build a single target / be verbose
cmake --build --preset dev --target vccli
cmake --build --preset dev --verbose
# Clean
rm -rf build/dev # nuke; or:
cmake --build --preset dev --target clean
``` ```
Other presets (see [`docs/building.md`](docs/building.md) for full detail): Apple client builds require macOS, Xcode, and the .NET macOS/iOS workloads:
```bash ```bash
cmake --preset skeleton # no-deps stub smoke (no VCPKG_ROOT needed) — 2 tests ./scripts/build-native-ios.sh
cmake --preset release # optimized + tests on, symbols kept (profile/debug-friendly) dotnet restore clients/apple/VoiceCat.Apple.slnx
cmake --preset server-release # optimized + stripped, no tests (deployment-shaped) dotnet build clients/apple/VoiceCat.Apple.slnx -c Debug --no-restore
cmake --preset windows-client # voicecat.dll for the C# WinForms client (Windows only)
cmake --preset apple-dev # libvoicecat.a for macOS Swift Package (scaffolding, macOS only)
``` ```
Vcpkg triplet is auto-resolved from the host platform by Windows publishing uses `clients/windows/publish-client.ps1`. Server publishing uses
[`cmake/voicecat-toolchain.cmake`](cmake/voicecat-toolchain.cmake) — `x64-mingw-static` on `scripts/publish-server.ps1`.
Windows, `x64-linux` on Linux, `arm64-osx` on Apple Silicon. See docs/building.md §1
"Platform matrix" for details.
One-time vcpkg setup: ## Current architecture
```bash - `proto/voicecat.proto` is the control-plane wire schema.
# one-time: git clone https://github.com/microsoft/vcpkg && ./vcpkg/bootstrap-vcpkg.sh (.bat on Windows) - `src/VoiceCat.Protocol` owns protobuf framing and generated types.
export VCPKG_ROOT=/path/to/vcpkg # works on Linux / macOS / Windows - `src/VoiceCat.Crypto` owns TLS, TOFU, media AEAD, identity, and password hashing.
``` - `src/VoiceCat.Server` owns the TLS/UDP server and SQLite state.
- `src/VoiceCat.Core` owns client connection and protocol state.
- `src/VoiceCat.Audio`, `.Codec`, and `.Dsp` own voice processing.
- `src/VoiceCat.Cli` is the supported headless client.
- `clients/windows` is the WinForms client.
- `clients/apple` contains the AppKit and UIKit clients.
- `native/media` and `native/rnnoise` are the required Opus/RNNoise native boundary.
- `native/apple/broadcast` is the required Swift ReplayKit extension.
Other useful toggles (pass with `-D` at configure time): ## Invariants
```bash - TLS control and encrypted UDP media are mandatory; do not add plaintext transports.
cmake --preset dev -DVOICECAT_BUILD_SHARED=ON # build libvoicecat as a .dll/.so/.dylib (for the C# client) - No GPL or LGPL dependencies.
cmake --preset dev -DVOICECAT_BUILD_SERVER=OFF # core + tools only - Audio callbacks must not allocate, lock, block, or perform network I/O.
cmake --preset dev -DVOICECAT_BUILD_TESTS=OFF - Treat `proto/voicecat.proto`, persisted database formats, and the ReplayKit ring layout as
``` explicit versioned contracts.
- A passing build is not enough: add or update behavior tests for observable changes.
Formatting: `clang-format` config is `.clang-format` (Google base, 100 cols, 4-space). - Keep `PROGRESS.md` concise; do not append a historical changelog.
```bash
git ls-files '*.cpp' '*.h' | xargs clang-format -i
```
---
## Architecture at a glance
Full detail: [`docs/architecture.md`](docs/architecture.md). The short version:
```
Swift (macOS/iOS) ─┐ ┌─ C# (Windows)
├──▶ libvoicecat (C ABI: voicecat.h) ◀──┤
voicecat-server ───┘ net · crypto · codec · protocol · └─ all UIs are thin
(links core) session · audio the core owns audio
```
- **One core, many faces.** Protocol, Opus, crypto, networking, jitter buffer, and mixing
live once in C++. Clients call the **C ABI** (`core/include/voicecat.h`); the server links
the same core, so framing/crypto never drift between ends.
- **Two transports.** TCP + **TLS 1.3** (control, protobuf `Envelope`) and UDP + **exported-key
ChaCha20-Poly1305 AEAD** (media, fixed binary voice frame). Encryption is mandatory.
- **Threading.** Real-time audio threads never allocate/lock/block; they exchange data with
the net thread via lock-free ring buffers; a worker pool absorbs blocking work.
### Subsystem map (code ↔ design doc)
| Path | Subsystem | Design |
|------|-----------|--------|
| `core/include/voicecat.h` | The C ABI (client/server contract) | architecture.md §4 |
| `core/proto/voicecat.proto` | Control-plane wire format (source of truth) | protocol.md |
| `core/src/net/` | Asio TCP/UDP transport, `[u32 len][payload]` framing | protocol.md §1, voice.md §2 |
| `core/src/crypto/` | TLS 1.3 (mbedTLS), media AEAD (libsodium), anti-replay | security.md |
| `core/src/codec/` | Opus encode/decode, FEC/DTX | voice.md §34 |
| `core/src/protocol/` | Envelope (de)serialize, request/response, dispatch | protocol.md |
| `core/src/session/` | Channels, users, streams, permissions, ephemeral text | protocol.md §5 |
| `core/src/audio/` | miniaudio I/O, APM DSP, jitter buffer, mixer | voice.md §811 |
| `core/src/core/` | `vc_client` — the handle behind the C ABI | architecture.md §4 |
| `server/` | Connection mgr, session registry, SFU relay, SQLite | architecture.md §5 |
| `tools/vccli/` | Headless client that drives/verifies the protocol | — |
| `clients/apple/`, `clients/windows/` | Native GUIs (M4) | architecture.md §4 |
---
## Documentation index (source of truth)
Read [`docs/`](docs/) before changing behavior. Order:
1. [docs/README.md](docs/README.md) — overview, locked decisions, glossary
2. [docs/architecture.md](docs/architecture.md) — core, C ABI, threading, server
3. [docs/protocol.md](docs/protocol.md) — control plane, Envelope, message catalog
4. [docs/voice.md](docs/voice.md) — UDP media, Opus, multi-stream, two-sided NR, VAD/PTT
5. [docs/security.md](docs/security.md) — mandatory encryption, TLS+AEAD, accounts, threat model
6. [docs/tech-stack.md](docs/tech-stack.md) — libraries, permissive-license rule, tooling
7. [docs/deployment.md](docs/deployment.md) — zero-config self-host (Docker / binary / source)
8. [docs/roadmap.md](docs/roadmap.md) — milestones + resolved decisions
9. [docs/building.md](docs/building.md) — what each CMake preset is for + manual server/`vccli` testing
---
## Keeping track of progress
**[`PROGRESS.md`](PROGRESS.md) is the living status file.** When you finish a task, check it
off there and note the next step, so the next agent can pick up instantly. Treat it as part of
the work, not an afterthought — update it in the same commit as the code.
---
## House rules (hard constraints)
- **A clean compile is the floor, not the goal.** "Done" = the milestone's observable exit
criterion in [`docs/roadmap.md`](docs/roadmap.md) passes (e.g. M1 = two `vccli` actually chat
over TLS). Encode it as a test. See [`AGENTS.md`](AGENTS.md).
- **No GPL/LGPL dependencies, ever** (closed-source redistribution is a goal). docs/tech-stack.md §5.
- **Encryption is mandatory** — never add a plaintext transport path. docs/security.md.
- **Real-time audio threads** never allocate, lock, or block. docs/architecture.md §3.
- **Keep docs + code in sync.** Changing a wire format (`voicecat.proto`) or the C ABI
(`voicecat.h`) is a deliberate, versioned act — update the doc in the same commit (protocol.md §8).
- Every commit must build (`cmake --build --preset dev`) and pass `ctest --preset dev`.
-60
View File
@@ -1,60 +0,0 @@
cmake_minimum_required(VERSION 3.25)
project(voicecat
VERSION 0.0.1
DESCRIPTION "Self-hosted native voice & text chat (see docs/)"
LANGUAGES CXX)
# On iOS, audio_engine.cpp includes miniaudio.h which pulls in AVFoundation Objective-C
# headers. Those cannot be compiled as C++; we set audio_engine.cpp's LANGUAGE to OBJCXX
# in core/CMakeLists.txt, but that requires the language to be enabled first.
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
enable_language(OBJCXX)
endif()
# ── Options ───────────────────────────────────────────────────────────────────
# The M0 skeleton compiles with NO third-party dependencies: every subsystem is a
# stub that returns VC_ERR_NOT_IMPLEMENTED. As each subsystem is built out, flip
# VOICECAT_USE_VCPKG_DEPS=ON so CMake pulls the real libraries (mbedTLS, libsodium,
# opus, protobuf, ...) via the vcpkg toolchain (see vcpkg.json / docs/tech-stack.md).
option(VOICECAT_USE_VCPKG_DEPS "Link real third-party deps via vcpkg" OFF)
option(VOICECAT_BUILD_SERVER "Build voicecat-server" ON)
option(VOICECAT_BUILD_TOOLS "Build the vccli headless test client" ON)
option(VOICECAT_BUILD_TESTS "Build tests" ON)
option(VOICECAT_BUILD_SHARED "Build libvoicecat as a shared library" OFF)
# ── Global settings ───────────────────────────────────────────────────────────
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE)
endif()
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
# ── Targets ───────────────────────────────────────────────────────────────────
add_subdirectory(core)
if(VOICECAT_BUILD_SERVER)
add_subdirectory(server)
endif()
if(VOICECAT_BUILD_TOOLS)
add_subdirectory(tools/vccli)
if(VOICECAT_USE_VCPKG_DEPS)
add_subdirectory(tools/voicecat-admin)
endif()
endif()
if(VOICECAT_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
message(STATUS "VoiceCat ${PROJECT_VERSION} configured "
"(vcpkg deps: ${VOICECAT_USE_VCPKG_DEPS}, "
"server: ${VOICECAT_BUILD_SERVER}, tools: ${VOICECAT_BUILD_TOOLS})")
-147
View File
@@ -1,147 +0,0 @@
{
"version": 6,
"cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 },
"configurePresets": [
{
"name": "vcpkg-common",
"hidden": true,
"description": "Shared base for all presets that link real deps via vcpkg. Uses cmake/voicecat-toolchain.cmake, which auto-resolves VCPKG_TARGET_TRIPLET / VCPKG_HOST_TRIPLET from the host platform (x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon). Cross-compile presets override VCPKG_TARGET_TRIPLET in their cacheVariables. Requires VCPKG_ROOT in the environment.",
"generator": "Ninja",
"toolchainFile": "${sourceDir}/cmake/voicecat-toolchain.cmake",
"cacheVariables": {
"VOICECAT_USE_VCPKG_DEPS": "ON"
}
},
{
"name": "skeleton",
"displayName": "Skeleton (no third-party deps)",
"description": "Builds the stub skeleton with just a C++20 compiler — no vcpkg needed. Subsystems return VC_ERR_NOT_IMPLEMENTED. Good for 'does the repo even build' smoke checks. Runs 2 tests (smoke + frame_codec).",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/skeleton",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"VOICECAT_USE_VCPKG_DEPS": "OFF"
}
},
{
"name": "dev",
"displayName": "Dev (full real-deps build, vcpkg)",
"description": "Day-to-day development preset. Real protocol, crypto, voice, server — everything from M1 onward. Builds server + tools + tests (21 tests). Auto-triplet: x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon. Requires VCPKG_ROOT.",
"inherits": "vcpkg-common",
"binaryDir": "${sourceDir}/build/dev",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"VOICECAT_BUILD_TOOLS": "ON",
"VOICECAT_BUILD_TESTS": "ON"
}
},
{
"name": "release",
"displayName": "Release (optimized, tests on, symbols kept)",
"description": "Optimized build with the full test suite enabled. Use to run tests against optimized code, profile, or catch optimizer-sensitive bugs. Symbols are kept (not stripped) so stack traces and profiling remain useful. Auto-triplet. Requires VCPKG_ROOT.",
"inherits": "vcpkg-common",
"binaryDir": "${sourceDir}/build/release",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"VOICECAT_BUILD_TOOLS": "ON",
"VOICECAT_BUILD_TESTS": "ON"
}
},
{
"name": "server-release",
"displayName": "Server Release (optimized, stripped, no tests)",
"description": "Production-shaped build for deployment. Optimized (Release) with stripped binaries (-s linker flag), no tests. This is what you'd ship/run — see docs/deployment.md. Auto-triplet. Requires VCPKG_ROOT.",
"inherits": "vcpkg-common",
"binaryDir": "${sourceDir}/build/server-release",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"VOICECAT_BUILD_TOOLS": "ON",
"VOICECAT_BUILD_TESTS": "OFF",
"CMAKE_EXE_LINKER_FLAGS": "-s",
"CMAKE_SHARED_LINKER_FLAGS": "-s"
}
},
{
"name": "windows-client",
"displayName": "Windows client (voicecat.dll for C# WinForms, M4)",
"description": "Produces a redistributable Release voicecat.dll with no MinGW runtime DLL dependencies (see core/CMakeLists.txt's static-runtime link flags and clients/windows/README.md). Server/tools/tests are off — this preset exists only to build the DLL. Windows only.",
"inherits": "vcpkg-common",
"binaryDir": "${sourceDir}/build/windows-client",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"VOICECAT_BUILD_SHARED": "ON",
"VOICECAT_BUILD_SERVER": "OFF",
"VOICECAT_BUILD_TOOLS": "OFF",
"VOICECAT_BUILD_TESTS": "OFF"
}
},
{
"name": "apple-dev",
"displayName": "Apple macOS (libvoicecat.a for Swift Package, scaffolding)",
"description": "SCAFFOLDING — not yet CI-validated; build on macOS to verify. Produces a static libvoicecat.a for macOS (arm64-osx on Apple Silicon, x64-osx on Intel) for consumption by the Swift Package / XCFramework. Server/tools/tests off. Requires VCPKG_ROOT.",
"inherits": "vcpkg-common",
"binaryDir": "${sourceDir}/build/apple-dev",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"VOICECAT_BUILD_SERVER": "OFF",
"VOICECAT_BUILD_TOOLS": "OFF",
"VOICECAT_BUILD_TESTS": "OFF"
}
},
{
"name": "apple-ios",
"displayName": "Apple iOS device (XCFramework slice)",
"description": "Cross-compiles a static libvoicecat.a for iOS device (arm64). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios.cmake (release-only, correct autoconf host triple).",
"inherits": "vcpkg-common",
"binaryDir": "${sourceDir}/build/apple-ios",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_SYSTEM_NAME": "iOS",
"CMAKE_SYSTEM_PROCESSOR": "arm64",
"CMAKE_OSX_ARCHITECTURES": "arm64",
"CMAKE_OSX_SYSROOT": "iphoneos",
"CMAKE_OSX_DEPLOYMENT_TARGET": "17.0",
"VCPKG_TARGET_TRIPLET": "arm64-ios",
"VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-overlays/triplets",
"VOICECAT_BUILD_SERVER": "OFF",
"VOICECAT_BUILD_TOOLS": "OFF",
"VOICECAT_BUILD_TESTS": "OFF"
}
},
{
"name": "apple-ios-sim",
"displayName": "Apple iOS simulator (XCFramework slice)",
"description": "Cross-compiles a static libvoicecat.a for iOS simulator (arm64-ios-simulator). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS simulator SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake (release-only, correct autoconf host triple).",
"inherits": "vcpkg-common",
"binaryDir": "${sourceDir}/build/apple-ios-sim",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_SYSTEM_NAME": "iOS",
"CMAKE_SYSTEM_PROCESSOR": "arm64",
"CMAKE_OSX_ARCHITECTURES": "arm64",
"CMAKE_OSX_SYSROOT": "iphonesimulator",
"CMAKE_OSX_DEPLOYMENT_TARGET": "17.0",
"VCPKG_TARGET_TRIPLET": "arm64-ios-simulator",
"VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-overlays/triplets",
"VOICECAT_BUILD_SERVER": "OFF",
"VOICECAT_BUILD_TOOLS": "OFF",
"VOICECAT_BUILD_TESTS": "OFF"
}
}
],
"buildPresets": [
{ "name": "skeleton", "configurePreset": "skeleton" },
{ "name": "dev", "configurePreset": "dev" },
{ "name": "release", "configurePreset": "release" },
{ "name": "server-release", "configurePreset": "server-release" },
{ "name": "windows-client", "configurePreset": "windows-client" },
{ "name": "apple-dev", "configurePreset": "apple-dev" },
{ "name": "apple-ios", "configurePreset": "apple-ios" },
{ "name": "apple-ios-sim", "configurePreset": "apple-ios-sim" }
],
"testPresets": [
{ "name": "skeleton", "configurePreset": "skeleton", "output": { "outputOnFailure": true } },
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
{ "name": "release", "configurePreset": "release", "output": { "outputOnFailure": true } }
]
}
+14
View File
@@ -0,0 +1,14 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<PropertyGroup Condition="'$(VoiceCatIosStatic)' == 'true'">
<RuntimeIdentifiers>ios-arm64;iossimulator-arm64</RuntimeIdentifiers>
<NuGetLockFilePath>packages.ios.lock.json</NuGetLockFilePath>
</PropertyGroup>
</Project>
+13
View File
@@ -0,0 +1,13 @@
<Project>
<PropertyGroup>
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == '' and '$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</VoiceCatNativeRid>
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</VoiceCatNativeRid>
<VoiceCatNativeDirectory Condition="'$(VoiceCatNativeDirectory)' == ''">$(MSBuildThisFileDirectory)artifacts/native/runtimes/$(VoiceCatNativeRid)/native</VoiceCatNativeDirectory>
</PropertyGroup>
<ItemGroup Condition="'$(MSBuildProjectName)' != 'VoiceCat.Server' and '$(MSBuildProjectName)' != 'VoiceCat.Crypto' and '$(MSBuildProjectName)' != 'VoiceCat.Protocol'">
<None Include="$(MSBuildThisFileDirectory)artifacts/native/licenses/*.txt" Link="licenses/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="$(VoiceCatNativeDirectory)/voicecat_media.dll" Condition="Exists('$(VoiceCatNativeDirectory)/voicecat_media.dll')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="$(VoiceCatNativeDirectory)/libvoicecat_media.so" Condition="Exists('$(VoiceCatNativeDirectory)/libvoicecat_media.so')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="$(VoiceCatNativeDirectory)/libvoicecat_media.dylib" Condition="Exists('$(VoiceCatNativeDirectory)/libvoicecat_media.dylib')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+27
View File
@@ -0,0 +1,27 @@
# syntax=docker/dockerfile:1.7
FROM mcr.microsoft.com/dotnet/sdk:10.0.203-noble AS build
WORKDIR /src
COPY global.json Directory.Build.props Directory.Build.targets ./
COPY proto/voicecat.proto proto/voicecat.proto
COPY src/ src/
RUN dotnet restore src/VoiceCat.Server/VoiceCat.Server.csproj -r linux-x64 --locked-mode -p:NuGetLockFilePath=packages.publish.linux-x64.lock.json
RUN dotnet publish src/VoiceCat.Server/VoiceCat.Server.csproj -c Release -r linux-x64 --self-contained true --no-restore \
-p:PublishSingleFile=true -p:PublishTrimmed=false -p:IncludeNativeLibrariesForSelfExtract=true -o /out \
&& mkdir /empty-data
FROM scratch AS export
COPY --from=build /out/VoiceCat.Server /VoiceCat.Server
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0.10-noble-chiseled-extra AS runtime
WORKDIR /app
COPY --from=build --chown=1654:1654 /out/ ./
COPY --from=build --chown=1654:1654 /empty-data/ /data/
USER 1654:1654
VOLUME ["/data"]
EXPOSE 8384/tcp
EXPOSE 8384/udp
ENV VOICECAT_DATA_DIR=/data \
VOICECAT_BIND_ADDRESS=0.0.0.0 \
VOICECAT_BIND_PORT=8384
HEALTHCHECK --interval=30s --timeout=6s --start-period=10s --retries=3 CMD ["/app/VoiceCat.Server", "--health-check", "127.0.0.1:8384"]
ENTRYPOINT ["/app/VoiceCat.Server"]
+57 -546
View File
@@ -1,546 +1,57 @@
# PROGRESS — VoiceCat # VoiceCat status
Living status. **Update this file in the same commit as your work** so the next agent picks Updated: 2026-09-22
up instantly. Newest status at the top.
## Current state
- **Date convention:** ISO (YYYY-MM-DD).
- Statuses: `[ ]` not started · `[~]` in progress · `[x]` done. VoiceCat's supported implementation is .NET 10. The managed protocol, crypto, TLS, server,
CLI, client state, audio engine, Windows client, macOS client, and iOS client are implemented.
--- Retired implementations and compatibility projects have been removed; this tree contains only
the supported product and its required native media boundaries.
## ▶ Where we left off / next action
The source-of-truth layout is:
- **Done (2026-06-21):** **iOS iPhone-layout UX fixes.** (1) Channels are now a **drill-down**
on iPhone — new `ChannelBrowserView` (root list of top-level channels) → `ChannelDetailView` - `proto/voicecat.proto` — wire schema.
(people in the channel + sub-channels + an explicit "Join Channel" button with password - `src/` — protocol, crypto, codec/DSP bindings, server, client core, audio, and CLI.
prompt). The iPad 3-column `NavigationSplitView` is unchanged. (2) Extracted a self-contained - `tests/VoiceCat.Tests/` — managed behavior and integration tests.
`UserRow` (context menu + sheets) from `UserListView` so admin actions are reused in the - `clients/windows/` — WinForms application over the managed core.
drill-down. (3) Fixed the **off-screen chat compose box**: `MainView` now places - `clients/apple/` — AppKit and UIKit applications over the managed core.
`VoiceControlsView` via `.safeAreaInset(edge: .bottom)` instead of a floating `.overlay`, so - `native/media/` and `native/rnnoise/` — required Opus/RNNoise shim and vendored RNNoise.
it reserves layout space above the tab bar and cooperates with keyboard avoidance. (4) - `native/apple/broadcast/` — required ReplayKit upload extension and shared ring producer.
**Collapsed Activity into Chat** like macOS/Windows: `ChatView` renders a merged, time-sorted
timeline of `messages` + `activityLog` (activity rows in gray); the separate Activity tab and The physical-device iOS voice path now supports ReplayKit fallback, stable Apple VPIO voice-chat
`ActivityLogView.swift` are removed. `xcodebuild` Debug for `generic/platform=iOS` BUILD capture using a paced 20 ms handoff, and true built-in stereo microphone capture. Stereo was
SUCCEEDED (sim slice still arm64-only → simulator run N/A). Next: on-device check of the verified on an iPhone 16 Pro Max with a two-channel AVAudioEngine input and distinct left/right
drill-down + compose box + unified timeline. samples; the managed Apple binding requires native use of its otherwise-unmapped stereo polar
pattern constant.
- **Done (2026-06-21):** **Screen-audio sharing on macOS + iOS.** macOS uses ScreenCaptureKit
(`ScreenAudioCapture.swift`) → `vc_stream_feed_pcm`; iOS uses a ReplayKit Broadcast Upload The iOS user list now opens a remote-user detail view with independent tuning for each active
Extension (`VoiceCatBroadcast`) that forwards captured `.audioApp` PCM through a shared App audio stream. Private messages are grouped into per-user conversations with direct access to the
Group SPSC ring (`BroadcastAudioRing.swift`) to the host's `BroadcastAudioPump`, which owns same user and audio controls.
the `SCREEN_AUDIO` stream and feeds it — single session, no creds on disk. No C++ changes
(the core was already ready). macOS `xcodebuild` Debug BUILD SUCCEEDED; iOS app + extension SQLite schema v4 persists DRED and the channel packet-loss mode. Manual loss remains the default;
build for device (the xcframework sim slice is arm64-only, so x86_64-simulator link is N/A). automatic Fast/Balanced/Stable modes measure each sender's authenticated UDP uplink at the server,
Next: on-device end-to-end verification (two clients hear the shared audio; iOS broadcast cap the applied Opus hint at 30%, and feed it back over TLS.
start/stop). NOTE: `ctest --preset dev` is 22/23 — `external_pcm` passes its assertions but
aborts at shutdown (`mutex lock failed`), a **pre-existing** teardown crash unrelated to this ## Release gates
change (no C++ was modified).
- Run real multi-person calls on Windows, macOS, and physical iOS hardware, including adaptive
- **Done (2026-06-20):** **macOS client UI overhaul** — mirrors the Windows client's UI 20/40/60 ms buffering, duration-aware DRED/FEC, automatic packet-loss feedback, and mismatched
overhaul (commit 97fa659 + 540ec13), adapted to Mac-native conventions. Also fixed and input/output endpoints.
verified the previously-uncompiled Swift changes from the external PCM feed/tap commit - Complete NVDA and VoiceOver navigation/announcement passes.
(615d2a8). The main window is now just toolbar + channels + users + chat; audio device - Verify iOS remote-user tuning and private-conversation navigation with VoiceOver, including
settings (input mode, VAD, PTT key, device picker, level meter) moved to a modeless multiple streams, users without active streams, and users who disconnect while a view is open.
Settings window (⌘,). Details in M5 section below. `swift test` 10/10; `xcodebuild` Debug - Exercise iOS background/lock, interruption, Bluetooth, route-change, ReplayKit, and iOS 27
+ Release BUILD SUCCEEDED with 0 Swift warnings. ScreenCaptureKit paths on devices. The background/lock gate keeps a call active for 15+ minutes
Next: live manual verification (toolbar toggles, unified log colors, PM windows, channel backgrounded and screen-locked with no periodic glitches and flat `VC_AUDIO` `feedDrops`/`starved`
counts, volume slider, settings window); then iOS ReplayKit and macOS ScreenCaptureKit counters (the render callback now paces the mix and the 20 ms capture handoff). Complete a
consumers of `vc_stream_feed_pcm`. 30-minute iOS call and Wi-Fi/cellular switching with voice restoration, plus extended
mono/stereo/voice-chat switching while joined. Verify Windows desktop/per-app stereo sharing.
- **Awaiting on-device verification:** **iOS stereo mic kills headphone/A2DP output — REAL - Complete Developer ID signing/notarization. The iOS host and ReplayKit extension have been
root cause found & fixed** (2026-06-20, on Windows; verify on Mac). All prior "fixes" (the distribution-signed and packaged locally; upload the IPA for Apple's server-side validation.
2026-06-19 entries below) targeted the Swift `IOSAudioRouter` on the false premise that - Run the published Linux container and a 30-minute-or-longer server soak.
"miniaudio does NOT touch AVAudioSession on iOS." **It does.** The core opened its miniaudio
devices with `ma_device_init(nullptr, ...)`; with a NULL context, miniaudio (0.11.25) runs an ## Working rule
iOS "hack" (`miniaudio.h` ~44057) that picks a session category by device type, then
`ma_context_init__coreaudio` (~36552) calls `setCategory()` + `setActive()` on **every device Keep this file short. It records only current state and open release gates. Git history is the
open** — capture → `AVAudioSessionCategoryRecord` with **zero options**. That wiped the implementation diary.
`.playAndRecord` category, the mode, and `.allowBluetoothA2DP`/`.mixWithOthers`/`.allowAirPlay`
that `IOSAudioRouter` had just configured → headphone/A2DP (and even wired) output died. The
stereo presets broke worst because they depend on the A2DP output route the wipe removed.
TeamTalk never hits this: its SDK opens RemoteIO/VPIO AudioUnits directly and leaves the
session entirely to the app (`UtilSound.swift`); miniaudio insists on managing it.
- **Fix (core, cross-platform safe):** `AudioEngine` now owns a `ma_context` built by
`make_context_config()` with `coreaudio.sessionCategory = ma_ios_session_category_none` +
`noAudioSessionActivate`/`noAudioSessionDeactivate = MA_TRUE`, and passes it to **all**
`ma_device_init` calls (playback, capture, loopback) and to `enumerate_devices`'s context.
miniaudio now never touches AVAudioSession; the Swift `IOSAudioRouter` is the sole owner
(session is already activated on connect in `AppState.swift:authResult`, before any device
opens, so removing miniaudio's self-activation is safe). Context is lazily inited in
`start()`, reused across restarts, uninited in `~AudioEngine`.
Files: `core/src/audio/audio_engine.{h,cpp}`.
- **TEMP diagnostics (remove after verification):** `AudioSessionManager.logSessionState(_:)`
logs category/mode/options/route; called after `ensureSessionActive`, on every route change,
and on `.streamStarted` (right after the core opens its devices). On Mac, watch the log when
joining voice with the Stereo Mic preset: category must stay `…PlayAndRecord` with
`allowBluetoothA2DP` and the output route must remain the headphones/A2DP device — NOT flip
to `…Record`. If confirmed, delete the `logSessionState` calls + method and the prior
band-aid comments in `IOSAudioRouter`/`audio_engine.cpp` can be trimmed.
- **Verified on Windows:** `cmake --build --preset dev` clean, `ctest --preset dev` 23/23
(22/22 prior + `test_external_pcm` new binary). iOS build & on-device run still to be done by the user on the Mac.
- **Done (2026-06-20):** **External PCM feed/tap API (`vc_stream_feed_pcm` +
`vc_set_pcm_sink`)** — see detail in M5 section below. `ctest --preset dev` 23/23 (was 22/22 + 1 new test binary with 3 sub-tests).
Next: iOS ReplayKit and macOS ScreenCaptureKit consumers of this API. A public,
documented API for driving audio streams with externally-provided PCM instead of (or in
addition to) miniaudio's hardware device. Motivated by four concrete use cases — all in our
roadmap — that the current "miniaudio owns the device" model can't serve:
1. **ReplayKit Broadcast Upload Extension (iOS `SCREEN_AUDIO`)** — the extension is a
*separate process* with a ~50 MB memory cap and can't link the full `AudioEngine`
(`ma_device`, capture/playback threads). It needs to feed `CMSampleBuffer` audio (system
app audio) into the encode path without any audio hardware. The current plan in
`docs/voice.md §9` says the extension links "a minimal slice of the core (Opus encode +
media send only)" — a public feed-PCM API *is* that minimal slice. The extension links
Opus + the feed entry point, no `ma_device` needed.
2. **ScreenCaptureKit (macOS `SCREEN_AUDIO`)**`SCStream` delivers `CMSampleBuffer` in a
callback; convert to int16 and feed. No need to route through miniaudio's device layer.
**This is how macOS screen-audio actually gets implemented** — today it does NOT work:
`VOICECAT_HAS_LOOPBACK` is Windows-only (`core/CMakeLists.txt:88-95`), so on macOS
`AudioEngine::start_loopback_capture()` hits the `#else` stub (`audio_engine.cpp:647-649`)
and returns `false`. The macOS client's "Share Screen Audio" button
(`MainWindowController.swift:800-816`) calls `startStream(.screenAudio)` which announces
the stream to peers but captures **zero audio** — peers hear silence. The button is left
in place (not touched per user request); it'll work once this API + a ScreenCaptureKit
tap ship on Mac.
3. **Bots** — music bot, TTS bot, radio relay, transcription bot. They create a
`SCREEN_AUDIO`/`AUX_DEVICE` stream and feed synthesized or decoded PCM via the feed API.
No audio hardware required — runs headless on a server. Today the only way to feed
external PCM is `vc_test_inject_capture` (TEST-ONLY, name signals "don't ship this") or
re-implementing Opus encode + AEAD + UDP framing yourself (~500 lines of duplicated
crypto/codec code per consumer).
4. **Custom clients / accessibility** — soundboard, DAW integration, TTS of incoming chat,
recording/transcription of remote audio. Need either feed (send) or tap (receive) or
both.
**What we already have (input half, gated as test-only):** `vc_test_inject_capture
(stream_id, pcm, samples)` (`voicecat.h`, `client.cpp:1452`) feeds raw int16 PCM into the
encode pipeline via `AudioEngine::inject_capture(kind, pcm, n)`. It works for any stream
kind, supports multiple concurrent injection taps (one ring buffer per local kind), and
goes through the full encode → AEAD → UDP path. The encode path already handles
`channels == 1 || 2` (proven by the WASAPI stereo loopback work, 2026-06-17 entry below).
The only problems: it's marked TEST-ONLY in the header, the name signals "don't use this in
production," and it hardcodes mono (no `channels` parameter).
**What's missing (output half):** today decoded remote audio is mixed and pushed to the
miniaudio playback device (`on_playback`). There's no way for an external consumer to
intercept the decoded PCM of a specific remote stream — it all goes to the hardware device.
A bot that wants to record, transcribe, or re-broadcast remote audio has no hook.
**Plan (API design — clean, append-only, no struct changes, ABI-stable):**
- **`vc_stream_feed_pcm`** — promote `vc_test_inject_capture` to a public, documented API
and add a `channels` parameter:
```c
/* External PCM feed — replaces the hardware capture device for this stream. Caller
provides interleaved int16 PCM at the stream's sample rate. The core frames it,
encodes (Opus), seals (AEAD), and sends (UDP). Works for any stream kind
(MIC/SCREEN_AUDIO/AUX_DEVICE). The stream must be started first (vc_stream_start);
this just replaces the capture source. channels = 1 (mono) or 2 (stereo interleaved).
Thread-safe; may be called from any thread including audio callbacks. */
vc_result vc_stream_feed_pcm(vc_client* c, uint32_t stream_id,
const int16_t* pcm, size_t samples_per_channel,
uint32_t channels);
```
- **`vc_set_pcm_sink`** — symmetric output side: receive decoded remote audio as int16 PCM
instead of (or in addition to) the hardware playback device:
```c
/* External PCM tap — receive decoded, mixed remote audio as int16 PCM. The callback
fires on the audio thread with the mixed output for a specific remote stream. Pass
cb=NULL to disable (default: disabled, hardware playback only). When enabled, PCM is
delivered to the sink AND the hardware device (dual output) so a bot can record
without disabling local monitoring. user_id+stream_id identify the source stream.
The callback MUST NOT block — copy what you need and return (same contract as
vc_callbacks.on_event). */
typedef void (*vc_pcm_sink_cb)(void* user, uint32_t user_id, uint32_t stream_id,
const int16_t* pcm, size_t samples_per_channel,
uint32_t channels, uint32_t sample_rate);
vc_result vc_set_pcm_sink(vc_client* c, vc_pcm_sink_cb cb, void* user);
```
- **Core changes:**
- `core/include/voicecat.h` — add `vc_pcm_sink_cb` typedef + the two function
declarations (append-only, after `vc_test_inject_capture`). Full doc comments on both
(contract, thread-safety, lifetime, use cases).
- `core/src/voicecat.cpp` — thin C trampolines → `vc_client::stream_feed_pcm` /
`set_pcm_sink`.
- `core/src/core/client.{h,cpp}` — `stream_feed_pcm`: validates `stream_id`, looks up
the `LocalStream`'s kind, calls `audio_engine_.inject_capture(kind, pcm, n)` (existing
path) with the channel count forwarded. `set_pcm_sink`: stores the callback + user
pointer; `on_playback` (or a new fan-out in the mixer) invokes it per remote stream
alongside the existing hardware write. Keep `vc_test_inject_capture` as a deprecated
alias calling `stream_feed_pcm(..., channels=1)` for source compatibility.
- `core/src/audio/audio_engine.{h,cpp}` — `inject_capture` already exists per-kind; add
a `channels` parameter to the ring-buffer write path (or a parallel stereo-aware
variant). The encode path in `client.cpp::on_capture_frame` already handles
`channels==2` via the stereo encode branch — just plumb the value through. For the
sink: add a `pcm_sink_` member (callback + user); in `on_playback` after mixing, if the
sink is set, copy the mixed PCM for the current stream and invoke the callback. The
copy must stay off the RT-critical path — document the non-blocking contract.
- **Skeleton stub path:** update `client.cpp`'s `#else` (no-deps) stub section to add
`vc_stream_feed_pcm`/`vc_set_pcm_sink` returning `VC_ERR_NOT_IMPLEMENTED` — keeps the
skeleton preset green.
- **Swift `VoiceCatCore`:** add `feedPcm(streamId:pcm:samplesPerChannel:channels:)` and
`setPcmSink(_:user:)` (the Swift wrapper around `vc_pcm_sink_cb` — a
`@convention(c)` closure + `Unmanaged` context, mirroring `Callbacks.swift`). Wraps both
new ABI functions.
- **C# `VoiceCat.Interop`:** add `StreamFeedPcm(streamId, pcm, samples, channels)` (with
`int16[]` marshaling) and `SetPcmSink` (delegates via `[UnmanagedCallersOnly]` thunk,
mirroring the event-callback pattern). Wraps both new ABI functions.
- **Tests:**
- `tests/test_external_pcm.cpp` (new) — `test_feed_pcm_round_trip`: two clients, A feeds
a known mono sine wave via `vc_stream_feed_pcm` on a MIC stream, B receives via the
normal decode path and asserts energy matches. `test_feed_pcm_stereo`: same with
`channels=2`, assert L≠R end-to-end (mirrors the WASAPI loopback stereo test).
`test_pcm_sink`: B sets a `vc_pcm_sink_cb`, A feeds PCM, assert the sink callback
receives the decoded PCM with matching energy. All headless, no audio hardware.
- `clients/apple/Tests/VoiceCatCoreTests/` — Swift wrapper round-trip for `feedPcm`.
- `clients/windows/VoiceCat.Interop.Tests/` — C# wrapper round-trip.
- **Docs:**
- `docs/architecture.md §4` — new subsection on external PCM feed/tap: the contract
(caller provides interleaved int16 at the stream's sample rate; core frames/encodes/
seals/sends for feed; core decodes/mixes/delivers for sink; sink callback must not
block), the use cases (ReplayKit, ScreenCaptureKit, bots, custom clients), and the
relationship to `vc_test_inject_capture` (deprecated alias).
- `docs/voice.md §9` — update the iOS ReplayKit and macOS ScreenCaptureKit rows: both
now consume `vc_stream_feed_pcm` instead of a "minimal slice of the core." Update the
iOS detail bullets: the extension links Opus + `vc_stream_feed_pcm` (not a parallel
media stack). Add a macOS ScreenCaptureKit note: convert `CMSampleBuffer` → int16,
feed via `vc_stream_feed_pcm` — this is how macOS screen-audio actually ships.
- `docs/protocol.md` — no protocol changes (the feed/sink are client-local; the wire
format is identical whether PCM came from miniaudio or an external source). Note this
explicitly.
- `docs/roadmap.md` — add a milestone entry; update the iOS ReplayKit and macOS
ScreenCaptureKit pending items to reference `vc_stream_feed_pcm`.
- **Implementation order:**
1. C ABI + core (`voicecat.h`, `voicecat.cpp`, `client.{h,cpp}`, `audio_engine.{h,cpp}`) +
skeleton stub. Verify `ctest --preset dev` green.
2. `tests/test_external_pcm.cpp` — the three behavior tests. Verify green.
3. Swift `VoiceCatCore` wrapper + `VoiceCatCoreTests` round-trip.
4. C# `VoiceCat.Interop` wrapper + `VoiceCatClientSmokeTests` round-trip.
5. Docs (`architecture.md`, `voice.md`, `protocol.md`, `roadmap.md`, header comments).
6. **Then** ReplayKit (iOS) and ScreenCaptureKit (macOS) become ~100-line consumers of
this API instead of parallel media stacks.
- **Verification:** `ctest --preset dev` green (3 new tests); `swift test` green; `dotnet
test` green; `xcodebuild` (skeleton) green. The feed/sink tests are fully headless — no
audio hardware, no simulator, no device — so they run in CI on every platform.
- **Files to touch:**
- Core C++: `core/include/voicecat.h`, `core/src/voicecat.cpp`,
`core/src/core/client.{h,cpp}`, `core/src/audio/audio_engine.{h,cpp}`.
- Tests: `tests/test_external_pcm.cpp` (new), `tests/CMakeLists.txt`.
- Swift: `clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift`,
`clients/apple/Sources/VoiceCatCore/Callbacks.swift`,
`clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift` (new).
- C#: `clients/windows/VoiceCat.Interop/VoiceCatClient.cs`,
`clients/windows/VoiceCat.Interop/NativeMethods.cs`,
`clients/windows/VoiceCat.Interop.Tests/ExternalPcmTests.cs` (new).
- Docs: `docs/architecture.md`, `docs/voice.md`, `docs/protocol.md`, `docs/roadmap.md`.
- **ABI stability:** append-only — two new functions + one new typedef, no existing
structs/enums changed. `vc_test_inject_capture` stays as a deprecated alias for source
compatibility. Treat as a deliberate, versioned ABI event per `docs/protocol.md §8`.
- **Relationship to the iOS audio routing plan:** orthogonal. The iOS routing layer controls
*which hardware route* miniaudio opens (AVAudioSession config in Swift). This plan is about
*bypassing miniaudio's hardware entirely* (external PCM feed/tap). Both ship; they don't
conflict. ReplayKit/ScreenCaptureKit consume this API; the iOS routing layer controls the
*mic* path which still uses miniaudio's device.
---
## Recent completed work
All items below are `[x]` done; `ctest --preset dev` 23/23 on Windows after all.
- **External PCM feed/tap API** (2026-06-20): `vc_stream_feed_pcm` + `vc_set_pcm_sink` shipped.
Promotes `vc_test_inject_capture` (mono-only, TEST-ONLY) to a public, stereo-capable API.
Adds symmetric PCM sink on the playback thread. Swift wrapper (`feedPcm`/`setPcmSink` in
`VoiceCatClient.swift`, 4 XCTest smoke tests). C# wrapper (`StreamFeedPcm`/`SetPcmSink` in
`VoiceCatClient.cs` + `NativeMethods.cs`, 4 xUnit smoke tests in `ExternalPcmTests.cs`).
Three new headless C++ ctests. Docs: architecture.md §4 new subsection, voice.md §9 updated,
protocol.md §8 explicit no-protocol-change note, roadmap.md M5 entry. Files: `voicecat.h`,
`voicecat.cpp`, `client.{h,cpp}`, `audio_engine.{h,cpp}`, `tests/test_external_pcm.cpp`,
`tests/CMakeLists.txt`, Swift + C# wrappers.
- **iOS A2DP + stereo root cause fix** (2026-06-20): miniaudio's NULL-context `ma_device_init`
was calling `AVAudioSession setCategory(Record)` on every device open, wiping the session
config `IOSAudioRouter` had set. Fixed by sharing a `ma_context` with
`sessionCategory=none` + `noAudioSessionActivate/Deactivate=MA_TRUE` — miniaudio never
touches `AVAudioSession`; `IOSAudioRouter` is the sole owner. Files: `audio_engine.{h,cpp}`.
- **iOS audio routing overhaul** (2026-06-19): Full `IOSAudioRouter` singleton drives all
`AVAudioSession` config before miniaudio opens devices. Fixed stereo mic polar-pattern setup
(WWDC20 recipe: `setPreferredInput` + `setInputDataSource` + `.stereo` polar pattern + no
`setPreferredInputNumberOfChannels`). Added `vc_audio_restart` ABI (full stop+reinit for
close→reconfigure→reopen ordering). Added `vc_set_capture_channels` ABI (core stereo-mic
support). AVAudioSession activated proactively on `.authResult`, not lazily on
`.streamStarted`. Join/Leave Voice button added (parity with macOS). Channel-id sync fixed
(mic button was permanently dimmed). iOS deployment target raised to 18.0.
- **iOS SwiftUI client** (2026-06-19): `VoiceCatiOS.xcodeproj` at `clients/apple/iOS/`.
Full feature parity with macOS/Windows: saved server list (JSON + Keychain, App Group
`group.cat.voice.VoiceCat`), TOFU, connect flow, channel tree, user list with context menus,
chat, admin sheets, voice controls, settings. `xcodebuild` → BUILD SUCCEEDED.
- **macOS AppKit client** (2026-06-18): `VoiceCatMac.xcodeproj` at `clients/apple/macOS/`.
Fixed compile errors (`NSAccessibility` call-site arg order, `StreamSummary.id` vs
`.streamId`) and linker issues (`OTHER_LDFLAGS = -lc++`, `ONLY_ACTIVE_ARCH = YES` for
Release). Debug + Release both BUILD SUCCEEDED.
- **Swift `VoiceCatCore` package + XCFramework** (2026-06-18): Shared Swift wrapper at
`clients/apple/`. `build-xcframework.sh` merges `libvoicecat.a` + 107 vcpkg static deps into
a fat `.a` via `libtool -static`. 6/6 Swift tests green (real server, mirrors C# Interop
tests). Supports macOS-arm64 + iOS-arm64 + iOS-sim slices.
- **macOS port validated** (2026-06-18): 21/21 on macOS. Three cross-platform bugs fixed:
missing `<netdb.h>` in POSIX test branch; SIGPIPE kills (added `SIG_IGN`); use-after-free of
Asio kqueue reactor on server shutdown (fixed `TcpAcceptor` shutdown/connection-drain
sequence).
- **CMake preset cleanup** (2026-06-18): `m1-dev`→`dev`, `dev`→`skeleton`, `m2-dev` dropped.
New `release`, `server-release` (stripped), `apple-dev`/`apple-ios`/`apple-ios-sim`. Cross-
platform triplet auto-resolved by `cmake/voicecat-toolchain.cmake`.
- **Disconnect, keepalive & reaper** (2026-06-18): Client sends `Ping` every 15 s; server
reaper drops sessions after 45 s; UDP `KEEPALIVE` every 5 s keeps NAT alive. `vc_disconnect`
sends graceful `Disconnect` proto. Stale-user LEFT broadcast on drop. PLC capped at ~2 s.
Three new tests: `test_disconnect_left`, `test_plc_cap`, `test_reaper_timeout`.
- **Stereo screen-audio loopback** (2026-06-17): WASAPI loopback opens in channel's
stereo/mono mode (was hardcoded mono). Real stereo flows end-to-end through loopback → encode
→ decode → mixer. New `test_loopback_stereo_capture`.
- **Windows screen-audio UI wired** (2026-06-17): `btnScreenShareToggle` in `MainForm.cs`.
No core/proto/ABI changes — all the plumbing was already there. `dotnet test` 4/4 green.
- **Bug fixes** (2026-06-16 2026-06-17):
- *AEAD nonce desync in SFU relay* — relay forwarded sender's `seq` verbatim; recipient
nonce reconstruction used the wrong counter. Fixed by rewriting the outgoing `seq` field
to the recipient's `peek_send_counter()`.
- *Playout clock free-ran* — `playout_ts` advanced even during VAD/PTT silence gaps,
eventually dropping all frames as too-late. Fixed with resync in `on_playback` via
`JitterBuffer::peek_front_ts()`.
- *Stale users after disconnect* — `ConnSession::close()` didn't broadcast `UserEvent::LEFT`
before erasing. Fixed; PLC cap added as defense-in-depth.
- *"Randomly bumped to Lobby"* — server excluded the actor from its own state-change
broadcasts. Fixed: `UserEvent::UPDATED` now goes to all clients including the actor.
- *Silent playback after join* — `opus_decode` received hardware callback frame count as
`max_samples` instead of the Opus frame size. Fixed with a decode ring buffer.
---
## Milestones (see [docs/roadmap.md](docs/roadmap.md) for full detail)
- [x] **M0 — Scaffolding** ✓ complete
- [x] **M1 — Control plane** ✓ complete (2026-06-15)
- [x] **M2 — Voice, single stream** ✓ complete (2026-06-16)
- [x] **M3 — Multi-stream & per-channel tuning** ✓ complete (2026-06-16)
- [x] **M4 — Native clients** — Windows WinForms ✓ (2026-06-17); macOS AppKit ✓ (2026-06-18); iOS SwiftUI ✓ (2026-06-19)
- [~] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …)
---
## M0 — Scaffolding ✓
Repo layout (`core/ server/ tools/ clients/ tests/`), CMake + vcpkg manifest, C ABI header
(`voicecat.h`), proto source of truth, core stubs for all six subsystems, `voicecat-server` +
`vccli` skeletons, smoke CTest, `.clang-format`/`.gitattributes`/`.gitignore`.
---
## M1 — Control plane ✓ (completed 2026-06-15)
**Exit criterion:** `test_m1_integration` — two clients authenticate over TLS 1.3 (guest +
Argon2id), exchange channel + private text. ~1 s.
`FrameCodec`, `TlsContext` (mbedTLS 1.3, ECDSA-P256 self-signed, TOFU pins TLS leaf-cert
SHA-256), `WorkerPool`, `Database` (SQLite + Argon2id), `ServerIdentityManager`,
`ConnSession` state machine, `SessionRegistry`, `vc_client` full M1 C ABI, `voicecat-admin`
CLI, dual-stack `TcpAcceptor`. **Key bug fixed:** `send_frame` double-framing — `encode_envelope`
was pre-framing the protobuf; fixed by passing raw protobuf bytes.
---
## M2 — Voice, single stream ✓ (completed 2026-06-16)
**Exit criterion:** `test_m2_voice` + `test_voice_client_abi` — two headless clients auth, bind
UDP, 50 Opus frames relayed + re-encrypted by SFU, B receives ≥25 and decrypts. ~4 s.
14-byte UDP voice header, `SodiumMediaCrypto` (ChaCha20-Poly1305 + 64-bit anti-replay),
`OpusEncoder`/`OpusDecoder` (FEC, PLC), `UdpMediaChannel`, `JitterBuffer`, `AudioEngine`
(miniaudio), `MediaRelay` SFU. **Key bug fixed:** `on_playback` passed hardware callback frame
count as `opus_decode` max_samples; fixed with a per-stream decode ring buffer.
---
## M3 — Multi-stream & per-channel tuning ✓ (completed 2026-06-16)
**Exit criterion:** `test_m3_multistream` — client A runs two concurrent streams (MIC +
SCREEN_AUDIO); B sees both; per-stream gain/mute/NR independent; effective Opus config matches
channel's server-enforced settings. ~2.4 s.
Fixed server `stream_id` counter bug (always wrote `1`). Per-channel `AudioConfig` populated
(Lobby: mono/24kbps/VOIP + DTX; Music Room: stereo/128kbps/AUDIO). `LocalStream` map,
`pending_announce_kind_`, `run_talk_timer()`, thread-join race in `teardown_voice()` fixed.
New C ABI: `vc_get_stream_audio_config`, `vc_test_inject_capture`.
---
## Post-M3 follow-up ✓ (completed 2026-06-16)
- **Device enumeration** — `vc_list_devices`/`vc_set_input_device`; opaque hex device ids;
`vc_free_device_list` now frees. Works pre-connect.
- **VAD/PTT gate** — `EnergyVadProcessor` (RMS threshold ~0.025, 300 ms hang-time);
`vc_set_input_mode`/`vc_set_push_to_talk`; MIC-only (SCREEN_AUDIO/AUX_DEVICE bypass).
- **True stereo playback** — `playback_channels=2`; stereo decoded L→L R→R in mixer; mono
upmixed L=R; hardware fallback to mono on failure.
- **WASAPI loopback** — `loopback_device_` with `ma_device_type_loopback`;
`VOICECAT_HAS_LOOPBACK` macro (Windows-only). `vccli --share-screen-audio`.
**Known deferred (still open):** AEC/NS/AGC (no working Windows/MSVC WebRTC APM build);
process-specific WASAPI loopback; RT-thread rule violation in `on_capture_frame` (mutex lock
on audio callback thread — pre-existing, needs lock-free ring-buffer refactor).
---
## M4 — Native clients ✓ (completed 2026-06-17 2026-06-19)
**Exit criterion:** `ctest --preset dev` 21/21 green; `dotnet build` 0 warnings; `xcodebuild`
BUILD SUCCEEDED (macOS + iOS); manually verified: connect, TOFU, channel tree, join, voice,
text, device pickers, level meter on each platform.
**New C ABI (additive):** `vc_list_channels`/`vc_list_users`/`vc_list_user_streams`,
`vc_join_channel`, `VC_EVENT_SERVER_IDENTITY` + `vc_confirm_server_identity`,
`vc_config::tofu_store_path`, `VC_INPUT_ALWAYS_ON`, `vc_set_vad_threshold`,
`vc_audio_suspend`/`vc_audio_resume`, `vc_audio_restart`, `vc_set_capture_channels`.
**Windows** (`clients/windows/`): `VoiceCat.Interop` (P/Invoke, `[UnmanagedCallersOnly]`),
`VoiceCat.App` (ConnectDialog, ServerIdentityDialog, MainForm with full M5 moderation UI,
PerUserTuningDialog, PttKeyCaptureDialog), `VoiceCat.Interop.Tests`. PTT is focus-scoped.
**macOS** (`clients/apple/macOS/VoiceCatMac.xcodeproj`): NSOutlineView channel tree,
NSTableView user list, NSTextView chat, voice controls, full VoiceOver accessibility, admin
menu, 17 Swift source files. `build-xcframework.sh` produces `VoiceCatCore.xcframework`.
**iOS** (`clients/apple/iOS/VoiceCatiOS.xcodeproj`): SwiftUI, `NavigationSplitView`/`TabView`,
`OutlineGroup` channel tree, `IOSAudioRouter` AVAudioSession driver, 24 Swift source files,
iOS 18.0 deployment target. App Group `group.cat.voice.VoiceCat` for Keychain sharing.
---
## M5 — Moderation, polish, and beyond [~] (in progress 2026-06-17)
**Exit criterion:** four ABI-level tests green (`test_m5_permissions`,
`test_m5_kick_ban_move_mute`, `test_m5_admin_accounts`, `test_m5_channel_crud`);
`vccli` can drive all moderation/admin/channel operations against a live server.
- [x] **Server-side moderation & permissions** — per-session `Permissions`, kick/ban/move/
server-mute, channel CRUD, DB schema v2 (`channels`, `bans`), BLAKE2b channel passwords.
- [x] **C ABI** — `vc_kick_user`, `vc_ban_user`, `vc_set_permission`, `vc_set_server_mute`,
`vc_move_user`, `vc_create_channel`, `vc_edit_channel`, `vc_delete_channel`,
`vc_create_account`, `vc_reset_password`, `vc_delete_account`, `vc_list_accounts`,
`vc_get_permissions`; events `VC_EVENT_GENERIC_RESULT`, `VC_EVENT_ACCOUNT_LIST`.
- [x] **Four M5 tests** passing — `ctest --preset dev` 21/21.
- [x] **vccli** M5 flags: `--kick`, `--ban`, `--move`, `--server-mute`/`-unmute`/`-deafen`/
`-undeafen`, `--set-permission`, channel CRUD, account CRUD, `--username`/`--password`.
- [x] **All three client UIs** (Windows WinForms, macOS AppKit, iOS SwiftUI) expose the full
M5 moderation and admin surface.
- [x] **Docs** — `docs/protocol.md`, `docs/security.md` kept in sync.
- [x] **DRED/audio-quality polish** — done (2026-06-20). `bool dred` added to `AudioConfig`
proto (field 11) and `vc_audio_config` C ABI. Encoder: `OPUS_SET_DRED_DURATION(2)` when
enabled (20 ms of ML redundancy per packet). Decoder: `OpusDREDDecoder` + per-stream
`OpusDRED` scratch pre-allocated; `JitterBuffer::try_copy_front_payload` peeks at the next
buffered packet on every PLC step; if DRED data is present, `opus_decoder_dred_decode`
reconstructs the lost frame — otherwise falls back to standard PLC. New test:
`test_dred_toggle` (ctest 22/22). Files: `voicecat.proto`, `voicecat.h`,
`opus_codec.{h,cpp}`, `audio_engine.{h,cpp}`, `client.cpp`, `session.{h,cpp}`.
- [ ] **DRED toggle in client UIs** — expose the `dred` flag in all three channel-config UIs
so admins can enable it per channel. Windows: `ChannelEditForm` / `vc_channel_info.audio.dred`
checkbox. macOS AppKit: channel-edit sheet. iOS SwiftUI: channel-edit form. All three UIs
already have full channel CRUD wired; this is an additive checkbox on the existing audio-config
section. (Core/protocol/ABI all done — this is UI-only work.)
- [x] **macOS ScreenCaptureKit screen-audio** — done 2026-06-21. `ScreenAudioCapture.swift`
drives an `SCStream` (audio-only, `excludesCurrentProcessAudio`), converts Float32 →
int16 in the channel's mono/stereo mode, and calls `vc_stream_feed_pcm`. Capture starts on
the self `.streamStarted` event (when the effective config is known); wired into
`MainWindowController.screenAudioClicked()`.
- [x] **iOS ReplayKit Broadcast Extension** (`VoiceCatBroadcast`) — done 2026-06-21.
Forward-to-host design: the extension (`SampleHandler.swift`) captures `.audioApp`,
converts to 48 kHz int16 stereo, and writes a shared App Group SPSC ring
(`BroadcastAudioRing.swift`); the host's `BroadcastAudioPump` owns the `SCREEN_AUDIO`
stream and feeds via `vc_stream_feed_pcm` (single session, no creds on disk). UI is an
`RPSystemBroadcastPickerView` in `VoiceControlsView`. (Replaced the speculative
`BroadcastCredentials.swift` self-connecting design, now removed.)
- [x] **External PCM feed/tap API** (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) — done
2026-06-20. Promotes `vc_test_inject_capture` (mono-only, TEST-ONLY) to a public API with
stereo support. Adds a symmetric PCM sink fired on the playback thread per decoded remote
stream. Full wrappers for Swift (`feedPcm`/`setPcmSink`) and C# (`StreamFeedPcm`/
`SetPcmSink`). Three new C++ ctests (`test_feed_pcm_round_trip`, `test_feed_pcm_stereo`,
`test_pcm_sink`), 4 Swift XCTest smoke tests, 4 C# xUnit smoke tests. Docs updated
(architecture.md §4 new subsection, voice.md §9 updated, protocol.md §8 explicit
no-protocol-change note, roadmap.md M5 entry). `ctest --preset dev` 23/23.
- [x] **macOS client UI overhaul** — done 2026-06-20. Mirrors the Windows client's UI
overhaul (toolbar, unified log, PM windows, channel counts, output volume, keyboard
shortcuts), adapted to Mac-native conventions:
- **NSToolbar**: Join Voice, Share Screen Audio, Mute, Deafen (SF Symbol toggle buttons),
and Output Volume slider (NSSlider 0100, default 80). Voice actions + mute/deafen +
output volume moved out of the bottom voice panel into the toolbar. Bottom panel keeps
input-mode segmented control / VAD slider / PTT key / device picker / level meter.
- **Unified log**: chat `NSTextView` + activity `NSTableView` collapsed into a single
`NSTextView` — activity events in `secondaryLabelColor` (gray), chat in default color.
Removed `activityTableView` and `activityLog` array.
- **Private messaging**: scope dropdown removed; compose bar always sends to the current
channel. Each PM conversation opens in its own modeless `PrivateMessageWindowController`
(NSWindow). Incoming `.textMessage` with `.private` scope routed to the right window;
outgoing PMs echoed by server arrive through the same path. "Send Private Message…"
added to user context menu. "New Private Message…" (⌘⇧N) opens `UserPickerSheet`
listing all server users.
- **Channel counts**: outline view renders `"Name (n)"` with live user counts;
`refreshChannelTree()` called on `.userJoined`/`.userLeft` (was missing).
- **Voice menu** (⌘⇧V join/leave, ⌘⇧S share screen, ⌘⇧M mute, ⌘⇧D deafen) and **Messages
menu** (⌘⇧N new PM) added to `NSApp.mainMenu` via `NSMenuItem` key equivalents with
`[.command, .shift]` mask. Removed on `windowWillClose`. Mac-native: ⌘ not Ctrl, dispatched
by the responder chain (no custom key monitor needed).
- **Output volume**: `setOutputVolume(_:)` wrapper added to `VoiceCatClient.swift` (was
missing — the C ABI + C# wrapper shipped in commit 97fa659 but the Swift wrapper was
never added). Wired end-to-end: toolbar slider → `client.setOutputVolume(gain)`.
- **Part A (uncompiled Swift fix)**: the external PCM feed/tap Swift wrapper (commit
615d2a8) was never compiled — the local xcframework predating the `voicecat.h` PCM
additions. Fixed: rebuilt xcframework (regenerated module map), fixed `UInt`→`Int` type
mismatch in `feedPcm` (Swift imports `size_t` as `Int` not `UInt`), added
`VoiceCatPcmSinkCallback` typealias (Swift-idiomatic alias for the C `vc_pcm_sink_cb`
so consumers don't need to directly import `VoiceCatC`). `swift test` 10/10 green.
- **Audio settings moved to Settings window**: the bottom voice panel (input mode, VAD
slider, PTT key, device picker, level meter) was removed from the main window and moved
into a new `SettingsWindowController` — a modeless window opened via the app menu's
"Settings…" (⌘,) item. The main window is now just toolbar + channels + users + chat.
Source-of-truth for audio settings (`selectedInputMode`, `vadThresholdValue`,
`selectedInputDeviceId`, `pttKeyCode`) lives in `MainWindowController` so voice start can
apply them even before the settings window has been opened; `SettingsWindowController`
reads from and writes back to those properties and applies changes to the client
immediately when voice is active. The level meter is forwarded from
`MainWindowController.handleLevel` → `settingsWindowController.updateLevel(rms:)`.
`keyCodeName` helper deduplicated (was duplicated in `PttKeyCaptureSheet.swift` +
`MainWindowController.swift` — now shared from `MainWindowController.swift`).
- Files: `MainWindowController.swift` (overhauled), `PrivateMessageWindowController.swift`
(new), `UserPickerSheet.swift` (new), `SettingsWindowController.swift` (new),
`VoiceCatClient.swift` (setOutputVolume + VoiceCatPcmSinkCallback typealias + feedPcm
type fix), `ExternalPcmTests.swift` (use typealias), `PttKeyCaptureSheet.swift` (removed
duplicate `keyCodeName`), `VoiceCatMac.xcodeproj/project.pbxproj` (register 3 new files).
- **Platform-specific adaptations** (vs. Windows): `NSToolbar` instead of `ToolStrip`;
global menu bar + `NSMenuItem` key equivalents (⌘ not Ctrl, responder-chain dispatched);
PM windows as modeless `NSWindow`s; picker as Mac sheet; gray = `secondaryLabelColor`;
SF Symbols for toolbar icons.
---
## Decisions log
All architecture/scope decisions are settled and recorded in
[docs/roadmap.md §2 "Resolved decisions"](docs/roadmap.md) and reflected across `docs/`.
If you make a *new* decision, record it there and link it here.
---
## How to update this file
1. Check off tasks as you complete them; flip a milestone to `[x]` only when its **exit
criterion test** passes.
2. Keep the **"Where we left off / next action"** block at the top accurate — it's the first
thing the next agent reads.
3. When you start a milestone, copy its task list from `docs/roadmap.md` into a section here.
+27 -50
View File
@@ -1,65 +1,42 @@
# VoiceCat # VoiceCat
Self-hosted, native voice & text chat in the spirit of classic TeamSpeak / Mumble — VoiceCat is a self-hosted, channel-based voice and text chat system built on .NET 10. It uses
channel-based voice, channel + private text, one server you run yourself. Plain **TCP** TLS 1.3 for protobuf control traffic and authenticated encrypted UDP for Opus media. There is
(control) and **UDP** (media), no WebRTC. Encrypted by default. A shared **C++ core** no WebRTC, central directory, or plaintext mode.
(`libvoicecat`) drives native clients (Swift on macOS/iOS, C# on Windows) and the server.
> **Status:** Design complete in [`docs/`](docs/). M1M5 are implemented — real TLS control The repository contains a managed server, CLI, shared client/audio core, and native Windows,
> plane, encrypted UDP voice (Opus), multi-stream, TOFU identity pinning, channel tree, macOS, and iOS user interfaces. A small C library supplies Opus/RNNoise, and a small Swift iOS
> permissions, moderation, disconnect/keepalive/reaper. Windows WinForms C# client shipped extension captures ReplayKit application audio.
> (M4). macOS/iOS Swift client is next. See [`PROGRESS.md`](PROGRESS.md) and
> [`docs/roadmap.md`](docs/roadmap.md).
## Read the design first
The [`docs/`](docs/) folder is the source of truth. Start at [`docs/README.md`](docs/README.md),
then `architecture``protocol``voice``security``tech-stack``deployment`
`roadmap`.
## Build ## Build
The default development preset is **`dev`** — it builds everything (server + tools + tests)
with real vcpkg deps. It works on Windows, Linux, and macOS (vcpkg triplet auto-resolved).
```bash ```bash
# one-time vcpkg setup: ./scripts/build-native.ps1
git clone https://github.com/microsoft/vcpkg && ./vcpkg/bootstrap-vcpkg.sh # .bat on Windows dotnet restore VoiceCat.slnx --locked-mode
export VCPKG_ROOT=/path/to/vcpkg # Linux/macOS; or $env:VCPKG_ROOT on PowerShell dotnet build VoiceCat.slnx -c Release --no-restore
dotnet test VoiceCat.slnx -c Release --no-build
# configure + build + test:
cmake --preset dev
cmake --build --preset dev
ctest --preset dev # 21 behavior tests
``` ```
Artifacts land in `build/dev/bin/` (`voicecat-server`, `vccli`, `voicecat-admin`). See [CLAUDE.md](CLAUDE.md) for the developer map, [docs/README.md](docs/README.md) for current
contracts, and [PROGRESS.md](PROGRESS.md) for the short release handoff.
The `skeleton` preset (no vcpkg deps, stubs only) is a fast smoke check that needs no
third-party libraries:
```bash
cmake --preset skeleton && cmake --build --preset skeleton && ctest --preset skeleton
```
See [`docs/building.md`](docs/building.md) for the full preset matrix (including `release`,
`server-release`, `windows-client`, and Apple platform scaffolding).
## Layout ## Layout
``` ```text
docs/ design spec (read this) proto/ protobuf wire schema
core/ libvoicecat — the shared C++ core src/ managed protocol, crypto, server, client, audio, and CLI
include/ voicecat.h (the C ABI all clients call) tests/ managed behavior and integration tests
proto/ voicecat.proto (control-plane wire format, source of truth) clients/windows/ WinForms client
src/ net/ crypto/ codec/ protocol/ session/ audio/ (stubs today) clients/apple/ AppKit and UIKit clients
server/ voicecat-server (headless; links the core) native/media/ narrow Opus/RNNoise C shim
tools/vccli/ headless test client — drives the protocol from M1 on native/rnnoise/ vendored RNNoise source and model
clients/ apple/ (Swift, M4) windows/ (C#, M4) — placeholders for now native/apple/broadcast/ ReplayKit broadcast extension
tests/ CTest targets docs/ current contracts and operating documentation
``` ```
## License ## Non-negotiable constraints
Permissive-only dependencies (no GPL/LGPL) so the project can be redistributed freely, - Encryption is mandatory.
including closed-source. Project license: TBD (see [`docs/tech-stack.md`](docs/tech-stack.md) §5). - No GPL or LGPL dependencies.
- Real-time audio callbacks never allocate, lock, block, or perform I/O.
- Accessibility is a release requirement on every client platform.
+18
View File
@@ -0,0 +1,18 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<Project Path="src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<Project Path="src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
<Project Path="src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
<Project Path="src/VoiceCat.Server/VoiceCat.Server.csproj" />
<Project Path="src/VoiceCat.Core/VoiceCat.Core.csproj" />
<Project Path="src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
<Project Path="src/VoiceCat.Cli/VoiceCat.Cli.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/VoiceCat.Tests/VoiceCat.Tests.csproj" />
</Folder>
<Folder Name="/clients/">
<Project Path="clients/windows/VoiceCat.Windows/VoiceCat.Windows.csproj" />
</Folder>
</Solution>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-64
View File
@@ -1,64 +0,0 @@
// swift-tools-version: 6.0
//
// VoiceCatCore the shared Swift core for the VoiceCat macOS (AppKit) and iOS (SwiftUI)
// clients. It wraps libvoicecat's C ABI (core/include/voicecat.h) as imported through the
// VoiceCatCore.xcframework binary target's module map (`import VoiceCatC`), and exposes a
// Swift-idiomatic, @MainActor-safe surface.
//
// Architecture: docs/architecture.md §4 ("one core, many faces"). The Windows C# client
// (clients/windows/VoiceCat.Interop) is the proven mirror of this same layering the Swift
// wrapper follows the same patterns (callback-lifetime, string-lifetime, event-delivery
// thread handoff, immediate vc_free_* on list reads) adapted to Swift's interop model.
//
// The XCFramework is a LOCAL BUILD ARTIFACT run `scripts/build-xcframework.sh` before
// `swift build` / `swift test`. See clients/apple/README.md.
import PackageDescription
let package = Package(
name: "VoiceCatCore",
// macOS 14 (Sonoma) is the AppKit client's deployment target. iOS 18 is the SwiftUI client
// target (clients/apple/iOS/) 18.0 unlocks the newest AVAudioSession APIs (stereo capture,
// polar patterns, data sources). Run `scripts/build-xcframework.sh --all` to produce all
// three slices: macos-arm64, ios-arm64, ios-arm64-simulator.
// swift-tools-version 6.0 is required for .iOS(.v18); swiftLanguageVersions .v5 keeps the
// Swift 5 language mode (avoids Swift 6 strict concurrency checking on pre-existing code).
platforms: [
.macOS(.v14),
.iOS(.v18),
],
products: [
.library(name: "VoiceCatCore", targets: ["VoiceCatCore"]),
],
targets: [
// Binary target the prebuilt static lib + headers + module map. Produced by
// scripts/build-xcframework.sh from the `apple-dev` CMake preset.
.binaryTarget(
name: "VoiceCatCoreXCF",
path: "VoiceCatCore.xcframework"
),
// The Swift wrapper library what the macOS/iOS apps import as `import VoiceCatCore`.
.target(
name: "VoiceCatCore",
dependencies: ["VoiceCatCoreXCF"],
path: "Sources/VoiceCatCore"
),
// Smoke tests against a real voicecat-server mirrors clients/windows/
// VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs. Requires the `dev` CMake preset
// to be built (build/dev/bin/voicecat-server + voicecat-admin).
//
// linkerSettings: libvoicecat.a is a static C++20 library (built by the apple-dev
// preset with vcpkg's clang), so the final executable must link libc++ (the LLVM C++
// standard library on macOS). vcpkg's static deps (mbedtls/sodium/opus/protobuf/
// sqlite3/spdlog/asio) are already compiled into the .a; macOS system frameworks
// (CoreAudio/CoreFoundation) are auto-discovered by the linker (PROGRESS.md).
.testTarget(
name: "VoiceCatCoreTests",
dependencies: ["VoiceCatCore"],
path: "Tests/VoiceCatCoreTests",
linkerSettings: [
.linkedLibrary("c++"),
]
),
],
swiftLanguageModes: [.v5]
)
+35 -120
View File
@@ -1,130 +1,45 @@
# Apple client (macOS + iOS) # Apple clients
Built in **M4** (see [`docs/roadmap.md`](../../docs/roadmap.md)). One shared **Swift core** `VoiceCat.Mac` and `VoiceCat.iOS` are .NET 10 AppKit and UIKit clients over the shared managed
(`VoiceCatCore` package) wrapping the C ABI ([`core/include/voicecat.h`](../../core/include/voicecat.h)), core. The ReplayKit upload extension under `native/apple/broadcast` remains Swift because it
with platform-specific UIs: **AppKit** for macOS (best VoiceOver accessibility), **SwiftUI** runs under the extension memory limit and writes the versioned shared audio ring.
for iOS. See [`docs/architecture.md`](../../docs/architecture.md) §4 and
[`docs/tech-stack.md`](../../docs/tech-stack.md) §2.
## What's here now Build on macOS with Xcode and the pinned .NET workloads:
### `VoiceCatCore` Swift Package — ✓ complete (2026-06-18)
The shared Swift core that both the macOS AppKit app and the iOS SwiftUI app will consume.
Mirrors the Windows client's `VoiceCat.Interop` layer ([`clients/windows/`](../windows/))
using Swift-native C interop instead of P/Invoke.
```
clients/apple/
├── Package.swift # SPM: binary target (XCFramework) + VoiceCatCore library + tests
├── VoiceCatCore.xcframework/ # BUILT ARTIFACT — produced by scripts/build-xcframework.sh (gitignored)
├── scripts/
│ └── build-xcframework.sh # builds libvoicecat + vcpkg deps → fat .a → XCFramework + module map
├── Sources/VoiceCatCore/
│ ├── Enums.swift # Swift-idiomatic mirrors of the 9 voicecat.h C enums
│ ├── Config.swift # VoiceCatConfig (wraps vc_config)
│ ├── Event.swift # VoiceCatEvent — copies ev.text inside the callback (the #1 lifetime rule)
│ ├── Models.swift # Channel, User, Stream, Device, Permissions, Account, AudioConfig, …
│ ├── Marshaling.swift # C arrays → Swift arrays + immediate vc_free_* (callers never manage native lifetime)
│ ├── Callbacks.swift # @convention(c) on_event/on_level + Unmanaged.passUnretained context bridging
│ └── VoiceCatClient.swift # the public Swift surface — owns vc_client*, all 38 C functions, event delivery on @MainActor
└── Tests/VoiceCatCoreTests/
└── VoiceCatClientSmokeTests.swift # 6 XCTest smoke tests against a real voicecat-server (6/6 green)
```
**Key patterns** (carried over from the proven C# `VoiceCat.Interop` — see
[`docs/architecture.md`](../../docs/architecture.md) §4 per-platform binding notes):
- **C interop via module map:** `import VoiceCatC` — Swift sees all C enums/structs/functions
directly. No manual struct/function redeclaration (unlike C# P/Invoke). The module map
(`module VoiceCatC { header "voicecat.h" }`) is staged into the XCFramework headers by
`build-xcframework.sh`.
- **`@convention(c)` callbacks:** plain C function pointers (not ARC-managed closures) +
`Unmanaged.passUnretained(self)` as the `user` context — the Swift analog of C#'s
`[UnmanagedCallersOnly]` + `GCHandle`. `deinit` calls `vc_client_destroy` (joins all
threads) before the object's memory is freed, so no callback can fire with a dangling
pointer.
- **Config string lifetimes:** the core stores raw pointers from `vc_config` (doesn't copy).
Native CString storage (`strdup`) is held for the client's entire lifetime, freed in
`deinit` after `vc_client_destroy`.
- **Event delivery:** events buffered in a lock-protected array + coalesced
`DispatchQueue.main` drain (one async block at a time) — the Swift analog of C#'s
`Channel<VoiceCatEvent>` + 30ms WinForms Timer pump. `ev.text` is copied to `String`
inside the callback before enqueueing (dangling-pointer rule).
- **Level meters:** coalesced to latest-per-stream-id (intermediate values are visually
irrelevant, same as C#'s `ConcurrentDictionary<uint,float>`).
- **Immediate `vc_free_*`** on list reads — callers never manage native list lifetime.
### Tests — 6/6 green
```
swift test
# ✓ testVersionStringIsNonEmpty
# ✓ testResultStringRoundTrips
# ✓ testConnectTofuAuthListChannelsRoundTrips (connect → TOFU → confirm → guest auth → channels → permissions → guest ListAccounts rejected)
# ✓ testAdminChannelCrudAccountCrudRoundTrips (admin auth → channel create/edit/delete → account create/list/reset/delete)
# ✓ testScreenAudioStreamStartsAndStops (screen-audio stream start/stop through Swift interop)
# ✓ testPerStreamRecvControlsRoundTrip (two clients, per-stream gain/mute/NR round-trip)
```
Prerequisites for tests: `cmake --preset dev && cmake --build --preset dev` (builds
`voicecat-server` + `voicecat-admin` into `build/dev/bin/`).
## What's NOT here yet (next steps)
- **macOS AppKit app** (`clients/apple/macOS/`) — the M4 UI: connect dialog, saved-server
list (Keychain for passwords), TOFU identity dialog, main window (NSOutlineView channel
tree, NSTableView user list, NSTextView chat, activity log), voice controls, per-user
tuning, full VoiceOver accessibility. Mirrors the Windows `VoiceCat.App` feature set.
- **iOS SwiftUI app** — AVAudioSession, mic permission, foreground voice.
- **`vc_audio_suspend`/`vc_audio_resume` ABI hooks** — deferred until the iOS client
milestone (keep ABI stable).
- **ReplayKit Broadcast Upload Extension** for iOS `SCREEN_AUDIO` ([`docs/voice.md`](../../docs/voice.md) §9).
- **macOS `SCREEN_AUDIO`** via ScreenCaptureKit (currently stub returns `false`).
- **iOS XCFramework slices** — `apple-ios` / `apple-ios-sim` presets are scaffolding; run
`scripts/build-xcframework.sh --all` once the iOS vcpkg triplets are validated.
## Building the XCFramework
The XCFramework is a **local build artifact** (gitignored, like the Windows client's
`build/windows-client/bin/voicecat.dll`). Run the build script before `swift build` /
`swift test`:
```bash ```bash
# Prerequisites: VCPKG_ROOT set, Xcode installed ./scripts/build-native.ps1
export VCPKG_ROOT=/path/to/vcpkg ./scripts/build-native-ios.sh
dotnet restore clients/apple/VoiceCat.Apple.slnx
# Build the macOS slice + fat static lib + XCFramework (validated) dotnet build clients/apple/VoiceCat.Apple.slnx -c Debug --no-restore
scripts/build-xcframework.sh
# → clients/apple/VoiceCatCore.xcframework/ (macOS-arm64 slice)
# Build all 3 slices (macOS + iOS device + iOS sim) — iOS still scaffolding
scripts/build-xcframework.sh --all
``` ```
### Fat static library Use `publish-macos.sh --dry-run` to validate a local ad-hoc macOS bundle. The dry-run build does
not enable hardened runtime because ad-hoc signatures have no Team ID and cannot satisfy macOS
library validation. The script normalizes nested signatures and installs the verified bundle at
both `VoiceCat.Mac/bin/Release/net10.0-macos27.0/osx-arm64/VoiceCat.app` and
`VoiceCat.Mac/bin/Release/distribution/VoiceCat.app`. Distribution builds remain hardened and
require `VOICECAT_CODESIGN_IDENTITY`; optional notarization uses `APPLE_ID`, `APPLE_TEAM_ID`, and
`APPLE_APP_PASSWORD`.
The `apple-dev` CMake preset produces a 1.9 MB `libvoicecat.a` containing only voicecat's For a physical iOS device, use `build-ios-device.sh` and `deploy-ios-device.sh`. The host and
own object files — vcpkg's static dependencies (protobuf, mbedtls, libsodium, opus, sqlite3, ReplayKit extension require signing profiles with App Group `group.me.iamtalon.voicecat`.
spdlog, asio, abseil, …) are 107 separate `.a` files under `vcpkg_installed/arm64-osx/lib/`. Hardware validation must cover VoiceOver, background and lock behavior, interruptions, route
A Swift Package binary target can only link ONE `.a` per XCFramework slice, so changes, Bluetooth, ReplayKit, and iOS 27 ScreenCaptureKit audio.
`build-xcframework.sh` merges them all into a single self-contained `libvoicecat-fat.a` For iOS voice stability, leave a call joined with the microphone active for at least 30 minutes
(~30 MB) using `libtool -static`. This is the Apple equivalent of how the Windows client and confirm speech stays clear and `VC_AUDIO` reports no growing `feedDrops`. While still joined,
ships a single `voicecat.dll` with all deps statically linked (via MinGW's `-static` flags toggle Wi-Fi off and on, switch between Wi-Fi and cellular, and confirm the app stays open,
in [`core/CMakeLists.txt`](../../core/CMakeLists.txt)). reconnects, and restores the voice session. Repeat with mono, stereo, and voice processing.
### Swift Package The iOS remote-user manual gate must also cover Users → user detail → independent microphone and
screen-audio gain/mute controls, microphone receive noise reduction, the no-active-stream state,
and Private Chats → conversation → User/audio settings. Repeat the navigation with VoiceOver and
disconnect the remote user while its detail and conversation views are open.
```bash App Store builds use the same device builder with `--configuration Release`. Set
swift build # builds VoiceCatCore library `VOICECAT_BUILD_NUMBER`, `VOICECAT_DISPLAY_VERSION`, `VOICECAT_DEVELOPMENT_TEAM`, the host
swift test # runs 6 smoke tests against a real voicecat-server `VOICECAT_CODESIGN_KEY`/`VOICECAT_CODESIGN_PROVISION` pair, and the extension
``` `VOICECAT_BROADCAST_CODESIGN_KEY`/`VOICECAT_BROADCAST_CODESIGN_PROVISION` pair. The host and
extension profiles must both be App Store Connect profiles and include the shared App Group.
The `Package.swift` declares: The complete TestFlight workflow is documented in `docs/apple-ios-release.md`. The shared ring
- A **binary target** (`VoiceCatCoreXCF`) pointing at the local `VoiceCatCore.xcframework`. contract is documented in `docs/broadcast-ring-format.md`.
- A **library target** (`VoiceCatCore`) that depends on the binary target and provides the
Swift wrapper.
- A **test target** (`VoiceCatCoreTests`) with `linkerSettings: [.linkedLibrary("c++")]`
the fat static lib is C++20, so the final executable must link libc++ (the LLVM C++ standard
library on macOS). vcpkg's static deps are already in the `.a`; macOS system frameworks
(CoreAudio/CoreFoundation) are auto-discovered by the linker.
@@ -1,51 +0,0 @@
// Callbacks the C function pointers passed to `vc_callbacks`. These are the Swift
// equivalent of the C# client's `[UnmanagedCallersOnly]` static methods (NativeCallbacks.cs).
//
// The critical patterns (carried over from the proven C# implementation):
// 1. `@convention(c)` closures plain C function pointers, NOT GC/ARC-managed closures.
// A @convention(c) closure cannot capture context, which is why the `user` pointer is
// used to resolve back to the VoiceCatClient instance (the C# version uses GCHandle for
// the same thing; Swift uses Unmanaged).
// 2. `Unmanaged.passUnretained(self).toOpaque()` as the `user` context a stable raw
// pointer to the Swift object WITHOUT incrementing the retain count. This is safe
// because `deinit` calls `vc_client_destroy` (which synchronously joins every internal
// thread) BEFORE the object's memory is freed so no callback can fire after the object
// is gone. (The C# equivalent: GCHandle.Alloc + GCHandle.Free in Dispose.)
// 3. Copy `ev.text` to a Swift `String` INSIDE `onEvent` (via `VoiceCatEvent.from(_:)`)
// before returning the raw pointer is dangling after the callback returns. This is
// the #1 lifetime rule from voicecat.h's vc_event doc comment.
import VoiceCatC
import Foundation
/// Internal: builds the `vc_callbacks` struct wired to VoiceCatClient's C function pointers.
/// The `user` context is an Unmanaged-passUnretained pointer to the client resolved back
/// to the client inside `onEvent`/`onLevel` below.
internal enum Callbacks {
/// The `on_event` C function pointer. Non-capturing @convention(c) closure resolves
/// the VoiceCatClient from `user` and enqueues a safe copy of the event.
static let onEvent: @convention(c) (
UnsafeMutableRawPointer?, UnsafePointer<vc_event>?
) -> Void = { user, ev in
guard let user, let ev else { return }
let client = Unmanaged<VoiceCatClient>.fromOpaque(user).takeUnretainedValue()
// Copy the event (including text) to a Swift value NOW the raw vc_event is
// invalid after this callback returns.
client.enqueueEvent(VoiceCatEvent.from(ev.pointee))
}
/// The `on_level` C function pointer. Coalesces to "latest sample per stream_id"
/// (intermediate values are visually irrelevant same as C#'s ConcurrentDictionary).
static let onLevel: @convention(c) (
UnsafeMutableRawPointer?, UInt32, Float
) -> Void = { user, streamId, rms in
guard let user else { return }
let client = Unmanaged<VoiceCatClient>.fromOpaque(user).takeUnretainedValue()
client.enqueueLevel(streamId, rms)
}
/// Construct the vc_callbacks struct for a given client.
static func make(user: UnsafeMutableRawPointer) -> vc_callbacks {
vc_callbacks(on_event: onEvent, on_level: onLevel, user: user)
}
}
@@ -1,30 +0,0 @@
// VoiceCatConfig Swift-idiomatic mirror of `vc_config` (voicecat.h). Passed to
// VoiceCatClient.init. The native CString storage for the string fields is held for the
// client's entire lifetime inside VoiceCatClient see VoiceCatClient.swift's doc comment
// on why (the core stores raw pointers from vc_config by value, it does not copy the data).
import VoiceCatC
/// Configuration for a `VoiceCatClient`. Mirrors `vc_config`.
public struct VoiceCatConfig: Sendable {
/// E.g. "VoiceCat-macOS". Forwarded in `ClientHello.client_name`.
public let clientName: String
/// E.g. "0.0.1". Forwarded in `ClientHello.client_version`.
public let clientVersion: String
public let logLevel: VoiceCatLogLevel
/// Path to the TOFU pin file (see `confirmServerIdentity` / docs/security.md §1.1).
/// nil = built-in relative default (only suitable for tests).
public let tofuStorePath: String?
public init(
clientName: String,
clientVersion: String,
logLevel: VoiceCatLogLevel = .info,
tofuStorePath: String? = nil
) {
self.clientName = clientName
self.clientVersion = clientVersion
self.logLevel = logLevel
self.tofuStorePath = tofuStorePath
}
}
@@ -1,155 +0,0 @@
// Swift-idiomatic mirrors of the voicecat.h C enums. Keep these in lockstep with
// core/include/voicecat.h values are append-only per the C ABI's house rule, so it's
// safe to add new cases at the end here too, but never renumber/remove existing ones.
//
// Swift imports the C enums directly via `import VoiceCatC` (e.g. VoiceCatC.VC_OK), but
// those case names are C-style (VC_ERR_NOT_IMPLEMENTED, VC_EVENT_SERVER_IDENTITY) these
// mirrors give the Swift UI and tests clean dot-syntax (VoiceCatResult.notImplemented,
// VoiceCatEventType.serverIdentity) and a typed bridge to/from the C values.
//
// NOTE: Swift's Clang importer brings C `typedef enum` types in as UInt32-backed enums
// (all our C enum values are non-negative), so these mirrors use UInt32 raw values too.
// The one signed field in the ABI `vc_event.result` is `int32_t` (not `vc_result`) is
// bridged via `UInt32(bitPattern:)` in Event.swift.
import VoiceCatC
/// Result codes mirrors `vc_result` (voicecat.h). Additive-only: new values go at the end.
public enum VoiceCatResult: UInt32, Sendable, Equatable {
case ok = 0
case notImplemented = 1
case invalidArg = 2
case notConnected = 3
case already = 4
case authFailed = 5
case permissionDenied = 6
case timeout = 7
case io = 8
case protocolError = 9
case crypto = 10
case audio = 11
case internalError = 12
/// Human-readable description from the core (vc_result_string returns a static literal).
public var description: String {
String(cString: vc_result_string(vc_result(rawValue)))
}
/// Bridge from the C enum.
public init(_ cValue: vc_result) { self = VoiceCatResult(rawValue: cValue.rawValue) ?? .internalError }
/// Bridge to the C enum.
public var cValue: vc_result { vc_result(rawValue) }
}
/// Log level mirrors `vc_log_level`.
public enum VoiceCatLogLevel: UInt32, Sendable, Equatable {
case trace = 0
case debug = 1
case info = 2
case warn = 3
case error = 4
case off = 5
public init(_ cValue: vc_log_level) { self = VoiceCatLogLevel(rawValue: cValue.rawValue) ?? .info }
public var cValue: vc_log_level { vc_log_level(rawValue) }
}
/// Connection state mirrors `vc_connection_state`.
public enum VoiceCatConnectionState: UInt32, Sendable, Equatable {
case disconnected = 0
case connecting = 1
case tlsHandshake = 2
case authenticating = 3
case connected = 4
/// M4: handshake succeeded, waiting on `confirmServerIdentity()`.
case verifyingIdentity = 5
public init(_ cValue: vc_connection_state) {
self = VoiceCatConnectionState(rawValue: cValue.rawValue) ?? .disconnected
}
public var cValue: vc_connection_state { vc_connection_state(rawValue) }
}
/// Text message scope mirrors `vc_text_scope`.
public enum VoiceCatTextScope: UInt32, Sendable, Equatable {
case channel = 0
case `private` = 1
case server = 2
public init(_ cValue: vc_text_scope) { self = VoiceCatTextScope(rawValue: cValue.rawValue) ?? .channel }
public var cValue: vc_text_scope { vc_text_scope(rawValue) }
}
/// Audio device kind mirrors `vc_device_kind`.
public enum VoiceCatDeviceKind: UInt32, Sendable, Equatable {
case input = 0
case output = 1
public init(_ cValue: vc_device_kind) { self = VoiceCatDeviceKind(rawValue: cValue.rawValue) ?? .input }
public var cValue: vc_device_kind { vc_device_kind(rawValue) }
}
/// Stream kind mirrors `vc_stream_kind`.
public enum VoiceCatStreamKind: UInt32, Sendable, Equatable {
case mic = 0
/// System/desktop audio (docs/voice.md §9).
case screenAudio = 1
case auxDevice = 2
public init(_ cValue: vc_stream_kind) { self = VoiceCatStreamKind(rawValue: cValue.rawValue) ?? .mic }
public var cValue: vc_stream_kind { vc_stream_kind(rawValue) }
}
/// Send-side input gate mode (docs/voice.md §11) mirrors `vc_input_mode`.
public enum VoiceCatInputMode: UInt32, Sendable, Equatable {
case voiceActivation = 0
case pushToTalk = 1
/// Transmit unconditionally, no VAD gate.
case alwaysOn = 2
public init(_ cValue: vc_input_mode) { self = VoiceCatInputMode(rawValue: cValue.rawValue) ?? .voiceActivation }
public var cValue: vc_input_mode { vc_input_mode(rawValue) }
}
/// Event type mirrors `vc_event_type`. Additive-only.
public enum VoiceCatEventType: UInt32, Sendable, Equatable {
case connectionState = 0
case authResult = 1
case channelList = 2
case userJoined = 3
case userLeft = 4
case userUpdated = 5
case textMessage = 6
case streamStarted = 7
case streamStopped = 8
case talkState = 9
case error = 10
case disconnected = 11
/// M4: reply to `joinChannel()` see `VoiceCatEvent.result` / `.channelId`.
case joinResult = 12
/// M4: the TOFU server-identity gate see `VoiceCatEvent.tofuStatus` / `.text`.
case serverIdentity = 13
/// M5: async result for moderation/admin/channel operations.
case genericResult = 14
/// M5: reply to `requestAccountList()` call `listAccounts()` to read.
case accountList = 15
public init(_ cValue: vc_event_type) {
self = VoiceCatEventType(rawValue: cValue.rawValue) ?? .error
}
public var cValue: vc_event_type { vc_event_type(rawValue) }
}
/// TOFU server-identity classification mirrors `vc_tofu_status`. Pins the TLS leaf
/// certificate's SHA-256 fingerprint (NOT the display-only Ed25519 value see
/// docs/security.md §1.1 and `VoiceCatServerIdentity`).
public enum VoiceCatTofuStatus: UInt32, Sendable, Equatable {
case firstConnect = 0
case matched = 1
case mismatch = 2
public init(_ cValue: vc_tofu_status) {
self = VoiceCatTofuStatus(rawValue: cValue.rawValue) ?? .firstConnect
}
public var cValue: vc_tofu_status { vc_tofu_status(rawValue) }
}
@@ -1,61 +0,0 @@
// VoiceCatEvent a Swift value type that is safe to hold/queue past the native callback's
// return. This is the Swift analog of the C# client's `VoiceCatEvent` record.
//
// CRITICAL (voicecat.h's vc_event doc comment): the native `vc_event.text` pointer is owned
// by the core and valid ONLY for the duration of the `on_event` callback. `from(_:)` copies
// it to a Swift `String` immediately never hold the raw `vc_event` across the callback
// boundary, or `text` will be a dangling pointer by the time it's read. This is the #1
// lifetime rule carried over from the Windows client (NativeCallbacks.cs / VoiceCatEvent.cs).
import VoiceCatC
/// A Swift-safe copy of a `vc_event`. Produced inside the `on_event` callback (see
/// Callbacks.swift) all pointer fields are converted to value types before the callback
/// returns.
public struct VoiceCatEvent: Sendable, Equatable {
public let type: VoiceCatEventType
public let connectionState: VoiceCatConnectionState
public let result: VoiceCatResult
public let userId: UInt32
public let channelId: UInt32
public let streamId: UInt32
public let textScope: VoiceCatTextScope
/// Generic small payload, meaning per event type. For `.serverIdentity` this is the
/// `VoiceCatTofuStatus`; for `.genericResult` the server error code; for `.talkState`
/// talking(0/1).
public let u32a: UInt32
/// Copied from the core's `vc_event.text` inside the callback. nil if the core passed NULL.
public let text: String?
public let timestampUnixMs: UInt64
/// Convenience: the TOFU status, valid when `type == .serverIdentity` (maps `u32a`).
public var tofuStatus: VoiceCatTofuStatus? {
type == .serverIdentity ? VoiceCatTofuStatus(rawValue: u32a) : nil
}
/// Copy a native `vc_event` into a safe Swift value. MUST be called inside the callback
/// while `ev.text` is still valid `String(cString:)` copies the bytes here.
@inline(__always)
public static func from(_ ev: vc_event) -> VoiceCatEvent {
let text: String?
if let raw = ev.text {
text = String(cString: raw) // copies safe to hold past callback return
} else {
text = nil
}
// ev.result is int32_t (not vc_result) per voicecat.h bridge via bitPattern.
// ev.u32a is uint32_t matches VoiceCatTofuStatus's UInt32 raw value directly.
return VoiceCatEvent(
type: VoiceCatEventType(ev.type),
connectionState: VoiceCatConnectionState(ev.connection_state),
result: VoiceCatResult(rawValue: UInt32(bitPattern: ev.result)) ?? .internalError,
userId: ev.user_id,
channelId: ev.channel_id,
streamId: ev.stream_id,
textScope: VoiceCatTextScope(ev.text_scope),
u32a: ev.u32a,
text: text,
timestampUnixMs: ev.timestamp_unix_ms
)
}
}
@@ -1,107 +0,0 @@
// Marshaling shared "walk a native array of owned-struct entries, convert to Swift value
// types, free the native list" pattern. Identical shape for vc_device_list / vc_channel_list
// / vc_user_list / vc_stream_summary_list / vc_account_list (all core-allocated, caller-freed
// per voicecat.h). The matching vc_free_*_list call happens INSIDE each function here,
// immediately after the conversion, so callers never need to remember to free anything
// themselves. This is the Swift analog of the C# client's Marshaling.cs.
import VoiceCatC
import Foundation
/// Internal marshaling helpers convert core-allocated C arrays to Swift arrays and
/// immediately free the native list. Not part of the public API.
internal enum Marshaling {
/// Convert a nullable `const char*` to a Swift `String` (empty if NULL).
@inline(__always)
static func string(_ ptr: UnsafePointer<CChar>?) -> String {
guard let ptr else { return "" }
return String(cString: ptr)
}
static func devices(_ list: inout vc_device_list) -> [Device] {
guard let items = list.items else { vc_free_device_list(&list); return [] }
var result: [Device] = []
result.reserveCapacity(list.count)
for i in 0..<list.count {
let d = items.advanced(by: i).pointee
result.append(Device(id: string(d.id), name: string(d.name), isDefault: d.is_default != 0))
}
vc_free_device_list(&list)
return result
}
static func channels(_ list: inout vc_channel_list) -> [Channel] {
guard let items = list.items else { vc_free_channel_list(&list); return [] }
var result: [Channel] = []
result.reserveCapacity(list.count)
for i in 0..<list.count {
let c = items.advanced(by: i).pointee
result.append(Channel(id: c.id, parentId: c.parent_id, name: string(c.name),
topic: string(c.topic), passwordProtected: c.password_protected != 0,
maxUsers: c.max_users))
}
vc_free_channel_list(&list)
return result
}
static func users(_ list: inout vc_user_list) -> [User] {
guard let items = list.items else { vc_free_user_list(&list); return [] }
var result: [User] = []
result.reserveCapacity(list.count)
for i in 0..<list.count {
let u = items.advanced(by: i).pointee
result.append(User(id: u.id, nickname: string(u.nickname), isGuest: u.is_guest != 0,
channelId: u.channel_id, selfMicMuted: u.self_mic_muted != 0,
selfDeafened: u.self_deafened != 0, serverMuted: u.server_muted != 0,
serverDeafened: u.server_deafened != 0))
}
vc_free_user_list(&list)
return result
}
static func streamSummaries(_ list: inout vc_stream_summary_list) -> [StreamSummary] {
guard let items = list.items else { vc_free_stream_summary_list(&list); return [] }
var result: [StreamSummary] = []
result.reserveCapacity(list.count)
for i in 0..<list.count {
let s = items.advanced(by: i).pointee
result.append(StreamSummary(streamId: s.stream_id, kind: VoiceCatStreamKind(s.kind),
label: string(s.label)))
}
vc_free_stream_summary_list(&list)
return result
}
static func accounts(_ list: inout vc_account_list) -> [Account] {
guard let items = list.items else { vc_free_account_list(&list); return [] }
var result: [Account] = []
result.reserveCapacity(list.count)
for i in 0..<list.count {
let a = items.advanced(by: i).pointee
result.append(Account(username: string(a.username), isAdmin: a.is_admin != 0,
createdAtUnixMs: a.created_at_unix_ms,
lastLoginUnixMs: a.last_login_unix_ms))
}
vc_free_account_list(&list)
return result
}
static func remoteStreamState(_ s: vc_remote_stream_state) -> RemoteStreamState {
RemoteStreamState(gain: s.gain, muted: s.muted != 0, noiseReduction: s.noise_reduction != 0)
}
static func audioConfig(_ c: vc_audio_config) -> AudioConfig {
AudioConfig(codec: c.codec, stereo: c.mode != 0, sampleRate: c.sample_rate,
bitrateBps: c.bitrate_bps, frameMs: c.frame_ms, application: c.application,
fec: c.fec != 0, expectedPacketLoss: c.expected_packet_loss,
dtx: c.dtx != 0, complexity: c.complexity)
}
static func permissions(_ p: vc_permissions) -> Permissions {
Permissions(canCreateTempChannel: p.can_create_temp_channel != 0,
canKick: p.can_kick != 0, canBan: p.can_ban != 0,
canMoveUsers: p.can_move_users != 0,
canAdminAccounts: p.can_admin_accounts != 0,
isAdmin: p.is_admin != 0)
}
}
@@ -1,236 +0,0 @@
// Plain Swift value types what survives past the native struct/free-list lifetime
// (Marshaling.swift converts the C structs into these and immediately frees the native
// list). Nothing here holds a raw pointer. This is the Swift analog of the C# client's
// Models.cs. Field naming follows Swift camelCase (the C structs use snake_case).
import VoiceCatC
/// Channel snapshot mirrors `vc_channel` (the pull-based view; re-call `listChannels()`
/// after `.channelList` / `.userJoined` / `.userLeft` / `.userUpdated` events).
public struct Channel: Sendable, Equatable, Identifiable {
public let id: UInt32
/// 0 = root.
public let parentId: UInt32
public let name: String
public let topic: String
public let passwordProtected: Bool
/// 0 = unlimited.
public let maxUsers: UInt32
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
passwordProtected: Bool, maxUsers: UInt32) {
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
self.passwordProtected = passwordProtected; self.maxUsers = maxUsers
}
}
/// Channel creation/edition descriptor mirrors `vc_channel_info`. Used by
/// `createChannel(_:)` and `editChannel(_:)`. `id == 0` means new channel (for create).
public struct ChannelEdit: Sendable, Equatable {
public let id: UInt32 // 0 = new channel for create
public let parentId: UInt32 // 0 = root
public let name: String
public let topic: String
public let passwordProtected: Bool
public let password: String? // nil/empty ignored if passwordProtected == false
public let maxUsers: UInt32 // 0 = unlimited
public let sortOrder: UInt32
/// 0/nil fields use server defaults.
public let audio: AudioConfig
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
passwordProtected: Bool, password: String?, maxUsers: UInt32,
sortOrder: UInt32, audio: AudioConfig) {
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
self.passwordProtected = passwordProtected; self.password = password
self.maxUsers = maxUsers; self.sortOrder = sortOrder; self.audio = audio
}
}
/// User snapshot mirrors `vc_user`.
public struct User: Sendable, Equatable, Identifiable {
public let id: UInt32
public let nickname: String
public let isGuest: Bool
public let channelId: UInt32
public let selfMicMuted: Bool
public let selfDeafened: Bool
public let serverMuted: Bool
public let serverDeafened: Bool
public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32,
selfMicMuted: Bool, selfDeafened: Bool, serverMuted: Bool,
serverDeafened: Bool) {
self.id = id; self.nickname = nickname; self.isGuest = isGuest; self.channelId = channelId
self.selfMicMuted = selfMicMuted; self.selfDeafened = selfDeafened
self.serverMuted = serverMuted; self.serverDeafened = serverDeafened
}
}
/// Permission bitset mirrors `vc_permissions` (M5).
public struct Permissions: Sendable, Equatable {
public let canCreateTempChannel: Bool
public let canKick: Bool
public let canBan: Bool
public let canMoveUsers: Bool
public let canAdminAccounts: Bool
public let isAdmin: Bool
public init(canCreateTempChannel: Bool, canKick: Bool, canBan: Bool,
canMoveUsers: Bool, canAdminAccounts: Bool, isAdmin: Bool) {
self.canCreateTempChannel = canCreateTempChannel; self.canKick = canKick; self.canBan = canBan
self.canMoveUsers = canMoveUsers; self.canAdminAccounts = canAdminAccounts; self.isAdmin = isAdmin
}
}
/// Account entry mirrors `vc_account` (M5, reply to `listAccounts()`).
public struct Account: Sendable, Equatable {
public let username: String
public let isAdmin: Bool
public let createdAtUnixMs: UInt64
public let lastLoginUnixMs: UInt64
public init(username: String, isAdmin: Bool, createdAtUnixMs: UInt64,
lastLoginUnixMs: UInt64) {
self.username = username; self.isAdmin = isAdmin
self.createdAtUnixMs = createdAtUnixMs; self.lastLoginUnixMs = lastLoginUnixMs
}
}
/// Per-user stream summary mirrors `vc_stream_summary`. For the full effective Opus
/// config of a specific (user_id, stream_id), use `VoiceCatClient.getStreamAudioConfig`.
public struct StreamSummary: Sendable, Equatable, Identifiable {
public let id: UInt32 // stream_id
public let kind: VoiceCatStreamKind
public let label: String
public init(streamId: UInt32, kind: VoiceCatStreamKind, label: String) {
self.id = streamId; self.kind = kind; self.label = label
}
}
/// Receive-side state the local listener chose for a specific remote stream mirrors
/// `vc_remote_stream_state`. All LOCAL (no protocol traffic) docs/voice.md §10.
/// Defaults (if `setRemoteStream` was never called): gain 1.0, unmuted, NR off.
public struct RemoteStreamState: Sendable, Equatable {
public let gain: Float // 0.0 ; default 1.0
public let muted: Bool
public let noiseReduction: Bool
public init(gain: Float, muted: Bool, noiseReduction: Bool) {
self.gain = gain; self.muted = muted; self.noiseReduction = noiseReduction
}
}
/// Audio device mirrors `vc_device`. `id` is an opaque, internally-encoded handle
/// (currently hex-encoded `ma_device_id`) always round-trip an id from `listDevices`;
/// never construct one by hand (docs/architecture.md §4).
public struct Device: Sendable, Equatable, Identifiable {
public let id: String
public let name: String
public let isDefault: Bool
public init(id: String, name: String, isDefault: Bool) {
self.id = id; self.name = name; self.isDefault = isDefault
}
}
/// iOS audio input port derived from `AVAudioSession.availableInputs`. Unlike the
/// miniaudio-based `Device` (which returns ~2 entries on iOS), this exposes the real
/// AVAudioSession input ports (builtInMic, bluetoothHFP, headsetMic, usbAudio, airPlay)
/// with their data sources (orientation: front/back/top/bottom) and polar patterns
/// (omni/cardioid/subcardioid/bidirectional). Used by `IOSAudioRouter` + `SettingsView`.
public struct IOSAudioInputPort: Identifiable, Hashable {
public let id: String // port UID (stable across route changes)
public let name: String // human-readable port name
public let portType: String // AVAudioSession.Port raw value as string
public let dataSources: [IOSAudioDataSource]?
public let isSelected: Bool // true if this is the current preferredInput
public init(id: String, name: String, portType: String,
dataSources: [IOSAudioDataSource]?, isSelected: Bool) {
self.id = id; self.name = name; self.portType = portType
self.dataSources = dataSources; self.isSelected = isSelected
}
}
/// iOS audio data source a sub-selection of an input port (e.g. built-in mic
/// orientation: front/back/top/bottom). May have polar pattern options.
public struct IOSAudioDataSource: Identifiable, Hashable {
public let id: String // dataSource UID
public let name: String // "Front", "Back", "Top", "Bottom"
public let polarPatterns: [String]? // AVAudioSession.PolarPattern raw values
public let isSelected: Bool // true if this is the current preferredDataSource
public let selectedPolarPattern: String?
public init(id: String, name: String, polarPatterns: [String]?,
isSelected: Bool, selectedPolarPattern: String?) {
self.id = id; self.name = name; self.polarPatterns = polarPatterns
self.isSelected = isSelected; self.selectedPolarPattern = selectedPolarPattern
}
}
/// iOS audio output route read-only display of `AVAudioSession.currentRoute.outputs`.
public struct IOSAudioOutputRoute: Identifiable, Hashable {
public let id: String // port UID
public let name: String // human-readable route name
public let portType: String // AVAudioSession.Port raw value as string
public init(id: String, name: String, portType: String) {
self.id = id; self.name = name; self.portType = portType
}
}
/// Effective Opus configuration mirrors `vc_audio_config`.
public struct AudioConfig: Sendable, Equatable {
public let codec: UInt32 // 0 = OPUS
public let stereo: Bool // mode: 0 = mono, 1 = stereo
public let sampleRate: UInt32
public let bitrateBps: UInt32
public let frameMs: UInt32
public let application: UInt32 // 0 = VOIP, 1 = AUDIO, 2 = LOWDELAY
public let fec: Bool
public let expectedPacketLoss: UInt32 // % 0..100
public let dtx: Bool
public let complexity: UInt32 // 0..10
public init(codec: UInt32 = 0, stereo: Bool = false, sampleRate: UInt32 = 48000,
bitrateBps: UInt32 = 64000, frameMs: UInt32 = 20, application: UInt32 = 0,
fec: Bool = true, expectedPacketLoss: UInt32 = 5, dtx: Bool = false,
complexity: UInt32 = 10) {
self.codec = codec; self.stereo = stereo; self.sampleRate = sampleRate
self.bitrateBps = bitrateBps; self.frameMs = frameMs; self.application = application
self.fec = fec; self.expectedPacketLoss = expectedPacketLoss; self.dtx = dtx
self.complexity = complexity
}
}
/// Stream descriptor mirrors `vc_stream_desc`. Used by `startStream(kind:deviceId:label:)`.
public struct StreamDescriptor: Sendable, Equatable {
public let kind: VoiceCatStreamKind
/// nil = default device for this kind.
public let deviceId: String?
public let label: String
public init(kind: VoiceCatStreamKind, deviceId: String? = nil, label: String) {
self.kind = kind; self.deviceId = deviceId; self.label = label
}
}
/// Server identity info parsed from a `.serverIdentity` event + `getServerIdentityDisplay()`.
/// The `tlsCertFingerprint` (SHA-256 hex of the TLS leaf cert) is the value the TOFU gate
/// actually pins on; `ed25519Fingerprint` is display-only (docs/security.md §1.1).
public struct ServerIdentity: Sendable, Equatable {
public let tofuStatus: VoiceCatTofuStatus
/// SHA-256 hex of the TLS leaf certificate the pinned value. No separators (64 chars).
public let tlsCertFingerprint: String
/// Ed25519 identity fingerprint from ServerHello, hex-formatted display only.
/// Empty if not yet available.
public let ed25519Fingerprint: String
public init(tofuStatus: VoiceCatTofuStatus, tlsCertFingerprint: String,
ed25519Fingerprint: String) {
self.tofuStatus = tofuStatus; self.tlsCertFingerprint = tlsCertFingerprint
self.ed25519Fingerprint = ed25519Fingerprint
}
}
@@ -1,591 +0,0 @@
// VoiceCatClient the public, Swift-idiomatic surface over libvoicecat. This is the Swift
// analog of the C# client's `VoiceCatClient.cs` (clients/windows/VoiceCat.Interop).
//
// Key patterns carried over from the proven C# implementation (see docs/architecture.md §4
// per-platform binding notes):
//
// 1. HANDLE OWNERSHIP: the class owns `vc_client*`; `deinit` calls `vc_client_destroy`
// (which synchronously joins every internal thread, so nothing can still be reading the
// config-string pointers or firing callbacks by the time it returns).
//
// 2. CONFIG STRING LIFETIMES: the core stores raw pointers from `vc_config` by value it
// does NOT copy the string data. `client_name`/`client_version`/`tofu_store_path` are
// read later, whenever `connect()` actually runs on the io_thread_. So the native CString
// storage (`_clientNamePtr` etc.) must outlive the WHOLE client, not just `init`. It's
// freed in `deinit`, AFTER `vc_client_destroy` has returned. (C#: Marshal.StringToCoTask
// MemUTF8 in ctor, FreeCoTaskMem in Dispose after destroy.)
//
// 3. EVENT DELIVERY THREAD HANDOFF: `on_event` fires on the core's event thread. Events are
// buffered in a lock-protected array and drained on `DispatchQueue.main` this is the
// boundary where the core's thread hands off to the UI thread. The C# analog is
// `Channel<VoiceCatEvent>` drained by a 30ms WinForms Timer; the Swift analog is a
// coalesced main-queue drain (only one async block scheduled at a time). `on_event`'s
// `text` is copied to a Swift `String` inside the callback (Callbacks.swift) before
// enqueueing the raw pointer is dangling by the time the main thread drains.
//
// 4. LEVEL METER COALESCING: `on_level` fires far more often than `on_event` and
// intermediate values are visually irrelevant coalesced to "latest sample per
// stream_id" in a lock-protected dictionary, drained on main alongside events.
// (C#: ConcurrentDictionary<uint,float> cleared in PumpEvents.)
//
// 5. IMMEDIATE vc_free_* ON LIST READS: `listChannels()`/`listUsers()`/etc. walk the native
// array, convert to Swift value types, and call `vc_free_*_list` INSIDE the function
// callers never manage native list lifetime. (C#: Marshaling.ToManaged does the same.)
import VoiceCatC
import Foundation
/// Swift-idiomatic alias for the C `vc_pcm_sink_cb` function-pointer type from
/// `voicecat.h`. Exposed publicly so consumers (`VoiceCatMac`, tests) can declare a sink
/// callback without directly importing the `VoiceCatC` C module. Mirrors the C# wrapper's
/// `VcPcmSinkCallback` delegate.
public typealias VoiceCatPcmSinkCallback = vc_pcm_sink_cb
/// The Swift wrapper around `vc_client*`. Owns the native handle for its entire lifetime;
/// `deinit` destroys it. Events and level meters are delivered on the main queue via the
/// `onEvent` / `onLevel` closures.
///
/// Thread-safety: the public methods are not thread-safe call them from the main thread
/// (the standard AppKit/SwiftUI pattern). The internal event/level buffers are thread-safe
/// (lock-protected) because they're written from the core's event thread.
public final class VoiceCatClient {
// MARK: - Stored properties
/// The opaque C handle (`vc_client*` Swift imports the incomplete C struct as
/// `OpaquePointer`). Set in `init`, passed to every C function, destroyed in `deinit`.
private var handle: OpaquePointer?
/// Unmanaged pointer to `self` passed as `vc_callbacks.user` so the C function-pointer
/// callbacks can resolve back to this instance. `passUnretained` (not `passRetained`)
/// because we want normal ARC to control the object's lifetime `deinit` calls
/// `vc_client_destroy` (joins all threads) before the object's memory is freed, so no
/// callback can fire with a dangling `user` pointer. See Callbacks.swift.
///
/// Computed (not stored) to break a circular init dependency: it needs `self`, but
/// stored properties must be initialized before `self` is available. `Unmanaged.passUn
/// retained(self).toOpaque()` always returns the same address for a given instance, so
/// computing it on demand is safe and consistent.
private var selfPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(self).toOpaque()
}
/// Native CString storage backing `vc_config` must outlive the whole client (the core
/// stores raw pointers, doesn't copy). Freed in `deinit` after `vc_client_destroy`.
private var clientNamePtr: UnsafeMutablePointer<CChar>?
private var clientVersionPtr: UnsafeMutablePointer<CChar>?
private var tofuStorePathPtr: UnsafeMutablePointer<CChar>?
// MARK: - Event / level delivery (main-queue)
/// Called on the main queue for every event, in order, never coalesced. Set this from
/// the main thread (AppKit/SwiftUI) to drive your UI.
public var onEvent: ((VoiceCatEvent) -> Void)?
/// Called on the main queue with the latest RMS level per stream_id since the last drain.
/// Intermediate values are coalesced (only the latest per stream_id is delivered).
public var onLevel: ((UInt32, Float) -> Void)?
/// Lock-protected buffers, written from the core's event thread, drained on main.
private let bufferLock = NSLock()
private var eventBuffer: [VoiceCatEvent] = []
private var levelSamples: [UInt32: Float] = [:]
private var drainScheduled = false
// MARK: - Init / deinit
/// Create a client. `config.clientName`/`clientVersion`/`tofuStorePath` are copied to
/// native CString storage held for the client's entire lifetime (the core reads them
/// later, e.g. when `connect()` runs on the io thread).
public init(config: VoiceCatConfig) {
// Allocate native C strings must persist until after vc_client_destroy in deinit.
// These don't need `self`, so they're safe to set first.
self.clientNamePtr = strdup(config.clientName)
self.clientVersionPtr = strdup(config.clientVersion)
self.tofuStorePathPtr = config.tofuStorePath.flatMap { strdup($0) }
self.handle = nil // placeholder set below after callbacks are wired
// All stored properties are now initialized `self` is fully available, so we can
// call `selfPointer` (the computed property) to build the callbacks struct.
var nativeConfig = vc_config()
nativeConfig.client_name = UnsafePointer(clientNamePtr)
nativeConfig.client_version = UnsafePointer(clientVersionPtr)
nativeConfig.log_level = config.logLevel.cValue
nativeConfig.tofu_store_path = UnsafePointer(tofuStorePathPtr)
let callbacks = Callbacks.make(user: selfPointer)
self.handle = vc_client_create(&nativeConfig, callbacks)
if handle == nil {
free(clientNamePtr); clientNamePtr = nil
free(clientVersionPtr); clientVersionPtr = nil
if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil }
fatalError("vc_client_create returned nil")
}
}
deinit {
if let handle {
// Joins every internal thread synchronously no callbacks can fire after this
// returns, so the selfPointer and config-string pointers are safe to free.
vc_client_destroy(handle)
self.handle = nil
}
// Free config strings AFTER destroy (the core may have been reading them up until
// destroy joined the io thread).
free(clientNamePtr); clientNamePtr = nil
free(clientVersionPtr); clientVersionPtr = nil
if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil }
}
// MARK: - Internal: event/level enqueue (called from the core's event thread)
/// Called by Callbacks.onEvent on the core's event thread. Buffers the event and
/// schedules a coalesced main-queue drain.
internal func enqueueEvent(_ event: VoiceCatEvent) {
bufferLock.lock()
eventBuffer.append(event)
let shouldSchedule = !drainScheduled
drainScheduled = true
bufferLock.unlock()
if shouldSchedule {
DispatchQueue.main.async { [weak self] in self?.drain() }
}
}
/// Called by Callbacks.onLevel on the core's event thread. Coalesces to latest-per-stream
/// and schedules a coalesced main-queue drain.
internal func enqueueLevel(_ streamId: UInt32, _ rms: Float) {
bufferLock.lock()
levelSamples[streamId] = rms
let shouldSchedule = !drainScheduled
drainScheduled = true
bufferLock.unlock()
if shouldSchedule {
DispatchQueue.main.async { [weak self] in self?.drain() }
}
}
/// Drains buffered events + coalesced levels on the main queue. Only one drain is
/// scheduled at a time (debounced via `drainScheduled`).
private func drain() {
bufferLock.lock()
let events = eventBuffer
eventBuffer.removeAll()
let levels = levelSamples
levelSamples.removeAll()
drainScheduled = false
bufferLock.unlock()
for event in events { onEvent?(event) }
for (streamId, rms) in levels { onLevel?(streamId, rms) }
}
// MARK: - Lifecycle (statics)
/// The core's version string (e.g. "VoiceCat 0.0.1 (protocol v1)"). Static literal never freed.
public static var versionString: String {
String(cString: vc_version_string())
}
/// Human-readable description of a result code. Static literal never freed.
public static func resultString(_ code: VoiceCatResult) -> String {
String(cString: vc_result_string(code.cValue))
}
// MARK: - Connection & auth (async; results via onEvent)
@discardableResult
public func connect(host: String, port: UInt16) -> VoiceCatResult {
VoiceCatResult(vc_connect(handle, host, port))
}
@discardableResult
public func disconnect() -> VoiceCatResult {
VoiceCatResult(vc_disconnect(handle))
}
@discardableResult
public func authenticateGuest(_ nickname: String) -> VoiceCatResult {
VoiceCatResult(vc_authenticate_guest(handle, nickname))
}
@discardableResult
public func authenticateUser(_ username: String, password: String) -> VoiceCatResult {
VoiceCatResult(vc_authenticate_user(handle, username, password))
}
// MARK: - TOFU server-identity gate (M4)
/// Accept or reject the pending server-identity check. Call after a `.serverIdentity`
/// event. `accept=true` on firstConnect/mismatch updates the pin file and proceeds;
/// `accept=false` aborts (emits `.disconnected` with `.crypto`). See docs/security.md §1.1.
@discardableResult
public func confirmServerIdentity(accept: Bool) -> VoiceCatResult {
VoiceCatResult(vc_confirm_server_identity(handle, accept ? 1 : 0))
}
/// The Ed25519 identity fingerprint from ServerHello, hex-formatted DISPLAY ONLY, not
/// the value the TOFU gate pins on (see docs/security.md §1.1). Empty if not yet available.
/// Uses the two-call idiom: query size with nil buffer, then allocate + fetch.
public func getServerIdentityDisplay() -> String {
var len: Int = 0
_ = vc_get_server_identity_display(handle, nil, 0, &len)
if len == 0 { return "" }
let buf = UnsafeMutablePointer<CChar>.allocate(capacity: len + 1)
defer { buf.deallocate() }
_ = vc_get_server_identity_display(handle, buf, len + 1, &len)
return String(cString: buf)
}
// MARK: - Channels
/// Join a channel. Result arrives as a `.joinResult` event (not via the return value,
/// which only reflects "request queued"). `password` is for password-protected channels.
@discardableResult
public func joinChannel(_ channelId: UInt32, password: String? = nil) -> VoiceCatResult {
VoiceCatResult(vc_join_channel(handle, channelId, password))
}
@discardableResult
public func leaveChannel() -> VoiceCatResult {
VoiceCatResult(vc_leave_channel(handle))
}
/// Pull the current channel tree. Re-call after `.channelList`/`.userJoined`/`.userLeft`/
/// `.userUpdated` events. The native list is freed inside this call callers never
/// manage native lifetime.
public func listChannels() -> [Channel] {
var native = vc_channel_list()
_ = vc_list_channels(handle, &native)
return Marshaling.channels(&native)
}
public func listUsers() -> [User] {
var native = vc_user_list()
_ = vc_list_users(handle, &native)
return Marshaling.users(&native)
}
public func listUserStreams(_ userId: UInt32) -> [StreamSummary] {
var native = vc_stream_summary_list()
let r = vc_list_user_streams(handle, userId, &native)
guard r == VC_OK else { return [] }
return Marshaling.streamSummaries(&native)
}
// MARK: - Local media streams
/// Start a mic / screen-audio / aux stream. Returns `(result, streamId)` `streamId`
/// is non-zero on success. The `label` and `deviceId` C strings are only needed for the
/// duration of the call (the core copies what it needs), so we use temporary strdup'd
/// buffers freed via `defer`.
@discardableResult
public func startStream(_ descriptor: StreamDescriptor) -> (VoiceCatResult, UInt32) {
var streamId: UInt32 = 0
let labelPtr = strdup(descriptor.label)
defer { free(labelPtr) }
let deviceIdPtr = descriptor.deviceId.flatMap { strdup($0) }
defer { if let deviceIdPtr { free(deviceIdPtr) } }
var desc = vc_stream_desc()
desc.kind = descriptor.kind.cValue
desc.device_id = deviceIdPtr.map { UnsafePointer($0) }
desc.label = UnsafePointer(labelPtr)
let r = vc_stream_start(handle, &desc, &streamId)
return (VoiceCatResult(r), streamId)
}
@discardableResult
public func stopStream(_ streamId: UInt32) -> VoiceCatResult {
VoiceCatResult(vc_stream_stop(handle, streamId))
}
@discardableResult
public func setInputDevice(streamId: UInt32, deviceId: String?) -> VoiceCatResult {
VoiceCatResult(vc_set_input_device(handle, streamId, deviceId))
}
/// Set the capture channel count for a local MIC stream (1 = mono, 2 = stereo interleaved).
/// Takes effect on the next AudioEngine restart (immediately if already running). Used by
/// the iOS `IOSAudioRouter` when the user picks stereo built-in mic capture.
@discardableResult
public func setCaptureChannels(streamId: UInt32, channels: UInt32) -> VoiceCatResult {
VoiceCatResult(vc_set_capture_channels(handle, streamId, channels))
}
// MARK: - External PCM feed / tap
/// External PCM feed drives a local stream's encode pipeline with caller-supplied PCM
/// instead of (or in addition to) a hardware capture device. Intended for ReplayKit
/// Broadcast Extension (iOS), ScreenCaptureKit (macOS), bots, and soundboard use cases.
///
/// - Parameters:
/// - streamId: The stream returned by `startStream`. Must be active.
/// - pcm: Raw int16 PCM pointer. Caller must keep the buffer alive for the duration of the call.
/// - samplesPerChannel: Samples per channel (e.g. 960 for 20 ms @ 48 kHz).
/// - channels: 1 (mono) or 2 (stereo interleaved L/R).
@discardableResult
public func feedPcm(streamId: UInt32, pcm: UnsafePointer<Int16>,
samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult {
VoiceCatResult(vc_stream_feed_pcm(handle, streamId, pcm,
samplesPerChannel, channels))
}
/// Convenience overload for feeding from a Swift `[Int16]` array.
@discardableResult
public func feedPcm(streamId: UInt32, pcm: [Int16],
samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult {
pcm.withUnsafeBufferPointer {
feedPcm(streamId: streamId, pcm: $0.baseAddress!,
samplesPerChannel: samplesPerChannel, channels: channels)
}
}
/// External PCM tap receive decoded per-stream audio as raw int16 PCM before it
/// reaches the hardware mix. Fires once per decoded Opus frame per remote stream.
///
/// The callback is a C function pointer (`@convention(c)`) receiving:
/// `(user, userId, streamId, pcm, samplesPerChannel, channels, sampleRate)`
///
/// Pass `nil` to disable (default). The callback MUST NOT block or allocate.
@discardableResult
public func setPcmSink(_ cb: VoiceCatPcmSinkCallback?, user: UnsafeMutableRawPointer?) -> VoiceCatResult {
VoiceCatResult(vc_set_pcm_sink(handle, cb, user))
}
@discardableResult
public func setInputMode(_ mode: VoiceCatInputMode) -> VoiceCatResult {
VoiceCatResult(vc_set_input_mode(handle, mode.cValue))
}
/// VAD threshold: normalized RMS 0.01.0 (default ~0.025). Takes effect immediately.
@discardableResult
public func setVadThreshold(_ threshold: Float) -> VoiceCatResult {
VoiceCatResult(vc_set_vad_threshold(handle, threshold))
}
@discardableResult
public func setPushToTalk(_ active: Bool) -> VoiceCatResult {
VoiceCatResult(vc_set_push_to_talk(handle, active ? 1 : 0))
}
@discardableResult
public func setSelfMute(micMuted: Bool, deafened: Bool) -> VoiceCatResult {
VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0))
}
/// Global playback volume applied after mixing all remote streams. gain 0.0 = silent,
/// 1.0 = unity (default), >1.0 amplifies. Always LOCAL no protocol traffic. Mirrors the
/// Windows client's `SetOutputVolume` and the C ABI `vc_set_output_volume` added in M5.
@discardableResult
public func setOutputVolume(_ gain: Float) -> VoiceCatResult {
VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain))
}
// MARK: - AVAudioSession interruption hooks (iOS)
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
@discardableResult
public func audioSuspend() -> VoiceCatResult {
VoiceCatResult(vc_audio_suspend(handle))
}
/// Resume miniaudio device I/O. Call after re-activating AVAudioSession.
@discardableResult
public func audioResume() -> VoiceCatResult {
VoiceCatResult(vc_audio_resume(handle))
}
/// Full audio engine restart uninitialize and re-initialize the capture and playback
/// devices so they pick up a new AVAudioSession route. Call this AFTER reconfiguring
/// AVAudioSession (setCategory, setPreferredInput, setPreferredPolarPattern, etc.) so the
/// core's devices reopen against the new route. Unlike `audioSuspend()`/`audioResume()`
/// (which only stop/start the existing devices, leaving them bound to the route that was
/// active when they were opened), this fully re-initializes them. Safe to call when the
/// engine is not running (it will just start it).
@discardableResult
public func audioRestart() -> VoiceCatResult {
VoiceCatResult(vc_audio_restart(handle))
}
// MARK: - Receive-side, per remote stream (LOCAL no protocol traffic; docs/voice.md §10)
@discardableResult
public func setRemoteStream(userId: UInt32, streamId: UInt32, gain: Float,
muted: Bool, noiseReduction: Bool) -> VoiceCatResult {
VoiceCatResult(vc_set_remote_stream(handle, userId, streamId, gain,
muted ? 1 : 0, noiseReduction ? 1 : 0))
}
public func getRemoteStream(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, RemoteStreamState?) {
var state = vc_remote_stream_state()
let r = vc_get_remote_stream(handle, userId, streamId, &state)
guard r == VC_OK else { return (VoiceCatResult(r), nil) }
return (VoiceCatResult(r), Marshaling.remoteStreamState(state))
}
public func getStreamAudioConfig(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, AudioConfig?) {
var cfg = vc_audio_config()
let r = vc_get_stream_audio_config(handle, userId, streamId, &cfg)
guard r == VC_OK else { return (VoiceCatResult(r), nil) }
return (VoiceCatResult(r), Marshaling.audioConfig(cfg))
}
// MARK: - Text
@discardableResult
public func sendText(scope: VoiceCatTextScope, targetId: UInt32, text: String) -> VoiceCatResult {
VoiceCatResult(vc_send_text(handle, scope.cValue, targetId, text))
}
// MARK: - Device enumeration (works pre-connect)
public func listDevices(_ kind: VoiceCatDeviceKind) -> [Device] {
var native = vc_device_list()
_ = vc_list_devices(handle, kind.cValue, &native)
return Marshaling.devices(&native)
}
// MARK: - M5: Moderation
@discardableResult
public func kickUser(_ userId: UInt32, reason: String? = nil) -> VoiceCatResult {
VoiceCatResult(vc_kick_user(handle, userId, reason))
}
@discardableResult
public func banUser(_ userId: UInt32, reason: String? = nil,
expiresUnixMs: UInt64 = 0) -> VoiceCatResult {
VoiceCatResult(vc_ban_user(handle, userId, reason, expiresUnixMs))
}
@discardableResult
public func setPermission(_ userId: UInt32, perms: Permissions) -> VoiceCatResult {
var native = vc_permissions()
native.can_create_temp_channel = perms.canCreateTempChannel ? 1 : 0
native.can_kick = perms.canKick ? 1 : 0
native.can_ban = perms.canBan ? 1 : 0
native.can_move_users = perms.canMoveUsers ? 1 : 0
native.can_admin_accounts = perms.canAdminAccounts ? 1 : 0
native.is_admin = perms.isAdmin ? 1 : 0
return VoiceCatResult(vc_set_permission(handle, userId, &native))
}
@discardableResult
public func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) -> VoiceCatResult {
VoiceCatResult(vc_set_server_mute(handle, userId, muted ? 1 : 0, deafened ? 1 : 0))
}
@discardableResult
public func moveUser(_ userId: UInt32, toChannel channelId: UInt32) -> VoiceCatResult {
VoiceCatResult(vc_move_user(handle, userId, channelId))
}
// MARK: - M5: Channel admin
@discardableResult
public func createChannel(_ info: ChannelEdit) -> VoiceCatResult {
var native = vc_channel_info()
Self.populateChannelInfo(&native, from: info)
defer { Self.freeChannelInfoStrings(&native) }
return VoiceCatResult(vc_create_channel(handle, &native))
}
@discardableResult
public func editChannel(_ info: ChannelEdit) -> VoiceCatResult {
var native = vc_channel_info()
Self.populateChannelInfo(&native, from: info)
defer { Self.freeChannelInfoStrings(&native) }
return VoiceCatResult(vc_edit_channel(handle, &native))
}
@discardableResult
public func deleteChannel(_ channelId: UInt32) -> VoiceCatResult {
VoiceCatResult(vc_delete_channel(handle, channelId))
}
// MARK: - M5: Account admin
@discardableResult
public func createAccount(_ username: String, password: String) -> VoiceCatResult {
VoiceCatResult(vc_create_account(handle, username, password))
}
@discardableResult
public func resetPassword(_ username: String, newPassword: String) -> VoiceCatResult {
VoiceCatResult(vc_reset_password(handle, username, newPassword))
}
@discardableResult
public func deleteAccount(_ username: String) -> VoiceCatResult {
VoiceCatResult(vc_delete_account(handle, username))
}
/// Request the account list result arrives as a `.accountList` event, then call
/// `listAccounts()` to pull the cached list.
@discardableResult
public func requestAccountList() -> VoiceCatResult {
VoiceCatResult(vc_list_accounts(handle))
}
public func listAccounts() -> [Account] {
var native = vc_account_list()
_ = vc_get_account_list(handle, &native)
return Marshaling.accounts(&native)
}
public func getPermissions() -> Permissions {
var native = vc_permissions()
_ = vc_get_permissions(handle, &native)
return Marshaling.permissions(native)
}
}
// MARK: - Helpers for vc_channel_info / vc_audio_config construction
extension VoiceCatClient {
/// Populate a `vc_channel_info` from a Swift `ChannelEdit`. The string fields
/// (`name`/`topic`/`password`) are strdup'd the caller MUST call
/// `freeChannelInfoStrings(_:)` after the C call returns (the core copies what it needs
/// during the call, so the temporary buffers can be freed via `defer`).
internal static func populateChannelInfo(_ native: inout vc_channel_info, from info: ChannelEdit) {
native.id = info.id
native.parent_id = info.parentId
native.name = UnsafePointer(strdup(info.name))
native.topic = UnsafePointer(strdup(info.topic))
native.password_protected = info.passwordProtected ? 1 : 0
native.password = (info.passwordProtected && !(info.password?.isEmpty ?? true))
? UnsafePointer(strdup(info.password!)) : nil
native.max_users = info.maxUsers
native.sort_order = info.sortOrder
native.audio = info.audio.toNative()
}
/// Free the strdup'd string fields of a `vc_channel_info` populated by
/// `populateChannelInfo`. Call this in a `defer` after the C call.
internal static func freeChannelInfoStrings(_ native: inout vc_channel_info) {
if let p = native.name { free(UnsafeMutablePointer(mutating: p)); native.name = nil }
if let p = native.topic { free(UnsafeMutablePointer(mutating: p)); native.topic = nil }
if let p = native.password { free(UnsafeMutablePointer(mutating: p)); native.password = nil }
}
}
extension AudioConfig {
/// Convert to a native `vc_audio_config`.
internal func toNative() -> vc_audio_config {
var n = vc_audio_config()
n.codec = codec
n.mode = stereo ? 1 : 0
n.sample_rate = sampleRate
n.bitrate_bps = bitrateBps
n.frame_ms = frameMs
n.application = application
n.fec = fec ? 1 : 0
n.expected_packet_loss = expectedPacketLoss
n.dtx = dtx ? 1 : 0
n.complexity = complexity
return n
}
}
@@ -1,68 +0,0 @@
// ExternalPcmTests Swift wrapper smoke tests for vc_stream_feed_pcm / vc_set_pcm_sink.
//
// These tests verify that the Swift API surface compiles, is callable, and returns expected
// results at the C-ABI boundary without requiring a live server or audio hardware.
// Full end-to-end relay / decode verification is covered by tests/test_external_pcm.cpp
// (C++ ctest), which runs headlessly on all platforms.
import XCTest
@testable import VoiceCatCore
final class ExternalPcmTests: XCTestCase {
// MARK: - feedPcm: API surface smoke
/// Calling feedPcm without a connected client or active stream must return .invalidArg
/// (not crash). Proves the SwiftC bridge compiles and handles the error path.
func testFeedPcm_noActiveStream_returnsInvalidArg() {
let client = VoiceCatClient(config: VoiceCatConfig(
clientName: "ext-pcm-test",
clientVersion: "0.1",
logLevel: .off
))
let sine = [Int16](repeating: 0, count: 960)
// Stream 0 doesn't exist the core must return invalidArg, not crash.
let result = client.feedPcm(streamId: 0, pcm: sine, samplesPerChannel: 960, channels: 1)
XCTAssertEqual(result, .invalidArg)
}
/// Calling feedPcm with channels=3 (invalid) must return .invalidArg.
func testFeedPcm_invalidChannels_returnsInvalidArg() {
let client = VoiceCatClient(config: VoiceCatConfig(
clientName: "ext-pcm-test",
clientVersion: "0.1",
logLevel: .off
))
let pcm = [Int16](repeating: 0, count: 960 * 3)
let result = client.feedPcm(streamId: 0, pcm: pcm, samplesPerChannel: 960, channels: 3)
XCTAssertEqual(result, .invalidArg)
}
// MARK: - setPcmSink: API surface smoke
/// setPcmSink(nil) on a freshly-created client must succeed (nil = disable, which is the
/// default state a no-op that must still return .ok).
func testSetPcmSink_nil_returnsOk() {
let client = VoiceCatClient(config: VoiceCatConfig(
clientName: "ext-pcm-test",
clientVersion: "0.1",
logLevel: .off
))
let result = client.setPcmSink(nil, user: nil)
XCTAssertEqual(result, .ok)
}
/// Calling setPcmSink with a @convention(c) function and then immediately disabling it
/// with nil must both succeed. Verifies the C-ABI function-pointer round-trip.
func testSetPcmSink_enableThenDisable_bothSucceed() {
let client = VoiceCatClient(config: VoiceCatConfig(
clientName: "ext-pcm-test",
clientVersion: "0.1",
logLevel: .off
))
let mySink: VoiceCatPcmSinkCallback = { _, _, _, _, _, _, _ in }
XCTAssertEqual(client.setPcmSink(mySink, user: nil), .ok)
XCTAssertEqual(client.setPcmSink(nil, user: nil), .ok)
}
}
@@ -1,505 +0,0 @@
// VoiceCatClientSmokeTests exercises the full connect TOFU auth channels
// moderation flow purely through the Swift wrapper layer (VoiceCatClient), against a real
// `voicecat-server` (the same binary the C++ ctest suite uses, built by `cmake --preset dev`).
// This is the Swift analog of clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs.
//
// Why this exists (same rationale as the C# tests): the C++ ctest suite proves the protocol
// works at the C++ level, but Swift-specific interop bugs @convention(c) callback lifetime,
// Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct
// field layout can only be caught by exercising the exact SwiftC boundary. These tests
// catch the same class of bugs the C# P/Invoke tests catch, for Swift.
//
// Prerequisites: `cmake --preset dev && cmake --build --preset dev` (builds voicecat-server
// and voicecat-admin into build/dev/bin/), AND `scripts/build-xcframework.sh` (builds the
// VoiceCatCore.xcframework that the Swift Package links).
import XCTest
import Foundation
@testable import VoiceCatCore
/// Manages a real `voicecat-server` process for the test suite's lifetime. Starts the server
/// on an ephemeral port (--port 0), parses the bound port from stdout, and provisions a known
/// admin account via `voicecat-admin`. Killed + cleaned up in deinit.
private final class ServerHarness {
let port: UInt16
private let process: Process
let tempDir: String
init() throws {
let repoRoot = Self.findRepoRoot()
let serverURL = URL(fileURLWithPath: repoRoot)
.appendingPathComponent("build/dev/bin/voicecat-server")
guard FileManager.default.isExecutableFile(atPath: serverURL.path) else {
throw NSError(domain: "VoiceCatTest", code: 1, userInfo: [
NSLocalizedDescriptionKey: "voicecat-server not found at \(serverURL.path)"
+ "build the dev preset first: cmake --preset dev && cmake --build --preset dev",
])
}
let tempDir = NSTemporaryDirectory() + "vc_swift_smoke_" + UUID().uuidString
try FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: true)
self.tempDir = tempDir
let p = Process()
p.executableURL = serverURL
p.arguments = ["--port", "0", "--data-dir", tempDir, "--name", "SwiftSmokeTest"]
// Pipe stdout to read the bound port; stderr to /dev/null.
let stdoutPipe = Pipe()
p.standardOutput = stdoutPipe
p.standardError = FileHandle(forWritingAtPath: "/dev/null")
try p.run()
self.process = p
// Parse "[voicecat-server] ... TCP :<port> UDP :<port>" from stdout. The server
// prints several lines before the port line (version, first-run admin box, etc.), so
// we keep reading until we find a line matching "TCP :<port>". Read with a 10s timeout
// so a crashed/hung server can't hang the test forever.
guard let port = Self.readPortWithTimeout(stdoutPipe, timeout: 10) else {
p.terminate()
throw NSError(domain: "VoiceCatTest", code: 2, userInfo: [
NSLocalizedDescriptionKey: "voicecat-server did not report a bound TCP port within 10s",
])
}
self.port = port
// Provision a known admin account for moderation/admin tests (M5).
let adminURL = URL(fileURLWithPath: repoRoot)
.appendingPathComponent("build/dev/bin/voicecat-admin")
guard FileManager.default.isExecutableFile(atPath: adminURL.path) else {
throw NSError(domain: "VoiceCatTest", code: 3, userInfo: [
NSLocalizedDescriptionKey: "voicecat-admin not found at \(adminURL.path)",
])
}
let adminProc = Process()
adminProc.executableURL = adminURL
adminProc.arguments = ["--data-dir", tempDir, "account", "add", "admin2",
"--admin", "--password", "testpassword123"]
adminProc.standardOutput = FileHandle(forWritingAtPath: "/dev/null")
adminProc.standardError = FileHandle(forWritingAtPath: "/dev/null")
try adminProc.run()
adminProc.waitUntilExit()
guard adminProc.terminationStatus == 0 else {
throw NSError(domain: "VoiceCatTest", code: 4, userInfo: [
NSLocalizedDescriptionKey: "voicecat-admin failed to provision admin2 (exit \(adminProc.terminationStatus))",
])
}
}
deinit {
if process.isRunning { process.terminate() }
try? FileManager.default.removeItem(atPath: tempDir)
}
private static func findRepoRoot() -> String {
var url = URL(fileURLWithPath: #file)
while url.path != "/" && !FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) {
url = url.deletingLastPathComponent()
}
guard FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) else {
fatalError("Could not find repo root (CMakePresets.json) above \(#file)")
}
return url.path
}
/// Read from the server's stdout until a line matching "TCP :<port>" is found, or the
/// timeout expires. The server prints several lines (version banner, first-run admin box,
/// etc.) before the port line see server/src/server.cpp.
private static func readPortWithTimeout(_ pipe: Pipe, timeout: TimeInterval) -> UInt16? {
let handle = pipe.fileHandleForReading
let deadline = Date().addingTimeInterval(timeout)
var buffer = Data()
while Date() < deadline {
let data = handle.availableData
if !data.isEmpty {
buffer.append(data)
// Check each complete line in the buffer for "TCP :<port>".
while let newlineIdx = buffer.firstIndex(of: 0x0A) {
let lineData = buffer.prefix(newlineIdx)
buffer = buffer.suffix(from: buffer.index(after: newlineIdx))
if let line = String(data: lineData, encoding: .utf8),
let port = parsePort(from: line) {
return port
}
}
}
Thread.sleep(forTimeInterval: 0.05)
}
return nil
}
private static func parsePort(from line: String) -> UInt16? {
// Match "TCP :<port>" see server/src/server.cpp.
guard let range = line.range(of: #"TCP :(\d+)"#, options: .regularExpression) else { return nil }
let digits = line[range].split(separator: ":").last ?? ""
return UInt16(digits.trimmingCharacters(in: .whitespaces))
}
}
/// XCTest smoke tests against a real voicecat-server, through the Swift VoiceCatClient wrapper.
final class VoiceCatClientSmokeTests: XCTestCase {
private static var harness: ServerHarness?
override class func setUp() {
do {
harness = try ServerHarness()
} catch {
// Store the error so each test fails with a clear message rather than a crash.
NSLog("ServerHarness setup failed: \(error.localizedDescription)")
harness = nil
}
}
override class func tearDown() {
harness = nil
}
private var port: UInt16 {
guard let p = Self.harness?.port else {
XCTFail("ServerHarness not started — see setUp error in log")
return 0
}
return p
}
private var tempDir: String {
Self.harness?.tempDir ?? NSTemporaryDirectory()
}
/// Helper: wait until the predicate is satisfied, running the main runloop to process
/// dispatched events. The Swift analog of the C# `PumpUntil` helper. Our events are
/// delivered via DispatchQueue.main.async, which the main runloop processes during
/// `RunLoop.current.run(until:)`.
///
/// Uses RunLoop polling (not XCTestExpectation) so that the "assert something does NOT
/// happen within N seconds" pattern works without generating spurious "Asynchronous wait
/// failed" errors `wait(for:timeout:)` logs an error when an expectation isn't
/// fulfilled, which is wrong for negative checks.
private func waitFor(timeout: TimeInterval = 5, _ predicate: @escaping () -> Bool) -> Bool {
if predicate() { return true }
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
// Run the main runloop for ~20ms processes DispatchQueue.main.async blocks
// (where our events/levels are drained) and timer sources.
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
if predicate() { return true }
}
return predicate()
}
private func requireHarness() -> Bool {
guard Self.harness != nil else {
XCTFail("ServerHarness not started — see setUp error in log")
return false
}
return true
}
// MARK: - Tests
func testVersionStringIsNonEmpty() {
XCTAssertFalse(VoiceCatClient.versionString.isEmpty)
}
func testResultStringRoundTrips() {
XCTAssertFalse(VoiceCatClient.resultString(.ok).isEmpty)
XCTAssertFalse(VoiceCatClient.resultString(.permissionDenied).isEmpty)
}
/// Full connect TOFU confirm guest auth list channels permissions guest
/// ListAccounts rejected. Mirrors the C# `Connect_Tofu_Auth_ListChannels_RoundTrips`.
func testConnectTofuAuthListChannelsRoundTrips() throws {
guard requireHarness() else { return }
var events: [VoiceCatEvent] = []
let client = VoiceCatClient(config: VoiceCatConfig(
clientName: "vc-swift-smoke",
clientVersion: "0.1",
logLevel: .off,
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins.txt")
))
client.onEvent = { events.append($0) }
XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
XCTAssertEqual(client.authenticateGuest("SwiftSmoke"), .ok)
// Wait for VC_EVENT_SERVER_IDENTITY.
XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } },
"did not receive .serverIdentity")
let identityEvent = try XCTUnwrap(events.first { $0.type == .serverIdentity })
XCTAssertEqual(identityEvent.tofuStatus, .firstConnect)
XCTAssertNotNil(identityEvent.text)
XCTAssertEqual(identityEvent.text?.count, 64, "SHA-256 hex, no separators")
// Auth must NOT complete before identity is confirmed (800ms, like the C# test).
XCTAssertFalse(waitFor(timeout: 0.8) { events.contains { $0.type == .authResult } },
"auth completed before identity confirmation (should be held open)")
XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
// Wait for VC_EVENT_AUTH_RESULT.
XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } },
"did not receive .authResult after confirming identity")
let authEvent = try XCTUnwrap(events.first { $0.type == .authResult })
XCTAssertEqual(authEvent.result, .ok)
// Wait for VC_EVENT_CHANNEL_LIST.
XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } },
"did not receive .channelList")
let channels = client.listChannels()
XCTAssertTrue(channels.contains { $0.id == 1 && $0.name == "Lobby" },
"expected Lobby (channel 1) in \(channels.map { $0.name })")
// M5: permissions getter round-trip.
let perms = client.getPermissions()
XCTAssertFalse(perms.isAdmin)
XCTAssertFalse(perms.canKick)
// M5: guest ListAccounts is rejected by the server with a GenericResult proves the
// moderation wrapper path works end-to-end through the Swift interop layer.
events.removeAll()
XCTAssertEqual(client.requestAccountList(), .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult } },
"did not receive .genericResult for guest ListAccounts")
let generic = try XCTUnwrap(events.first { $0.type == .genericResult })
XCTAssertEqual(generic.result, .permissionDenied)
client.disconnect()
}
/// Admin auth channel CRUD account CRUD. Mirrors C# `Admin_ChannelCrud_AccountCrud_RoundTrips`.
func testAdminChannelCrudAccountCrudRoundTrips() throws {
guard requireHarness() else { return }
var events: [VoiceCatEvent] = []
let client = VoiceCatClient(config: VoiceCatConfig(
clientName: "vc-swift-admin",
clientVersion: "0.1",
logLevel: .off,
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_admin.txt")
))
client.onEvent = { events.append($0) }
XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
XCTAssertEqual(client.authenticateUser("admin2", password: "testpassword123"), .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } })
XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } })
XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } })
let perms = client.getPermissions()
XCTAssertTrue(perms.isAdmin || perms.canAdminAccounts)
// Channel CRUD create.
let audioConfig = AudioConfig(stereo: true, bitrateBps: 64000, frameMs: 20,
application: 1, fec: true, expectedPacketLoss: 5, complexity: 10)
XCTAssertEqual(client.createChannel(ChannelEdit(
id: 0, parentId: 0, name: "Swift Test Channel", topic: "Created by Swift smoke test",
passwordProtected: false, password: nil, maxUsers: 42, sortOrder: 0, audio: audioConfig
)), .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
"CreateChannel did not succeed")
var channels = client.listChannels()
let created = try XCTUnwrap(channels.first { $0.name == "Swift Test Channel" })
XCTAssertEqual(created.topic, "Created by Swift smoke test")
XCTAssertFalse(created.passwordProtected)
// Channel CRUD edit.
events.removeAll()
XCTAssertEqual(client.editChannel(ChannelEdit(
id: created.id, parentId: created.parentId, name: created.name,
topic: "Updated topic", passwordProtected: false, password: nil,
maxUsers: 100, sortOrder: 0, audio: audioConfig
)), .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
"EditChannel did not succeed")
// Channel CRUD delete.
events.removeAll()
XCTAssertEqual(client.deleteChannel(created.id), .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
"DeleteChannel did not succeed")
// Account CRUD create.
events.removeAll()
XCTAssertEqual(client.createAccount("swift_smoke_user", password: "initialpw"), .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
"CreateAccount did not succeed")
// Account CRUD list.
events.removeAll()
XCTAssertEqual(client.requestAccountList(), .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .accountList } },
"did not receive .accountList")
let accounts = client.listAccounts()
XCTAssertTrue(accounts.contains { $0.username == "swift_smoke_user" })
// Account CRUD reset password.
events.removeAll()
XCTAssertEqual(client.resetPassword("swift_smoke_user", newPassword: "newpw123"), .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
"ResetPassword did not succeed")
// Account CRUD delete.
events.removeAll()
XCTAssertEqual(client.deleteAccount("swift_smoke_user"), .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
"DeleteAccount did not succeed")
client.disconnect()
}
/// Screen-audio (SCREEN_AUDIO) stream start/stop through the Swift wrapper. The core's
/// macOS CoreAudio path starts the StreamAnnounce; this exercises the full
/// startStream .streamStarted stopStream .streamStopped path through Swift interop.
/// Mirrors C# `ScreenAudioStream_Starts_And_Stops`.
func testScreenAudioStreamStartsAndStops() throws {
guard requireHarness() else { return }
var events: [VoiceCatEvent] = []
let client = VoiceCatClient(config: VoiceCatConfig(
clientName: "vc-swift-screen",
clientVersion: "0.1",
logLevel: .off,
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_screen.txt")
))
client.onEvent = { events.append($0) }
XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
XCTAssertEqual(client.authenticateGuest("SwiftScreen"), .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } })
XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } })
XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok)
XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } })
// Give the async UDP binding handshake a moment to land (mirrors vccli's 500ms sleep).
Thread.sleep(forTimeInterval: 0.5)
let (startResult, streamId) = client.startStream(
StreamDescriptor(kind: .screenAudio, label: "Desktop audio")
)
XCTAssertEqual(startResult, .ok)
XCTAssertNotEqual(streamId, 0, "streamId should be non-zero on success")
// The core emits .streamStarted for the local client too.
XCTAssertTrue(waitFor(timeout: 5) {
events.contains { $0.type == .streamStarted && $0.streamId == streamId }
}, "did not receive .streamStarted for screen-audio stream")
XCTAssertEqual(client.stopStream(streamId), .ok)
XCTAssertTrue(waitFor(timeout: 5) {
events.contains { $0.type == .streamStopped && $0.streamId == streamId }
}, "did not receive .streamStopped for screen-audio stream")
client.disconnect()
}
/// Per-stream receive-side controls (gain/mute/NR) round-trip through Swift: two clients
/// in a channel, one publishes a MIC stream, the other setRemoteStream's it then
/// getRemoteStream's it back. Catches Swift-specific marshaling bugs (field order,
/// bool-from-int, float precision) that the C++ ctest can't. Mirrors C#
/// `PerStream_RecvControls_Round_Trip_Through_PInvoke`.
func testPerStreamRecvControlsRoundTrip() throws {
guard requireHarness() else { return }
var eventsA: [VoiceCatEvent] = []
var eventsB: [VoiceCatEvent] = []
let a = VoiceCatClient(config: VoiceCatConfig(
clientName: "vc-swift-mix-a", clientVersion: "0.1", logLevel: .off,
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_a.txt")
))
let b = VoiceCatClient(config: VoiceCatConfig(
clientName: "vc-swift-mix-b", clientVersion: "0.1", logLevel: .off,
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_b.txt")
))
a.onEvent = { eventsA.append($0) }
b.onEvent = { eventsB.append($0) }
// Connect + auth A first, then B (staggering avoids concurrent TLS handshakes).
XCTAssertEqual(a.connect(host: "127.0.0.1", port: port), .ok)
XCTAssertEqual(a.authenticateGuest("SwiftMixA"), .ok)
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .serverIdentity } })
XCTAssertEqual(a.confirmServerIdentity(accept: true), .ok)
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .authResult } })
XCTAssertEqual(try XCTUnwrap(eventsA.first { $0.type == .authResult }).result, .ok)
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .channelList } })
XCTAssertEqual(b.connect(host: "127.0.0.1", port: port), .ok)
XCTAssertEqual(b.authenticateGuest("SwiftMixB"), .ok)
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .serverIdentity } })
XCTAssertEqual(b.confirmServerIdentity(accept: true), .ok)
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .authResult } })
XCTAssertEqual(try XCTUnwrap(eventsB.first { $0.type == .authResult }).result, .ok)
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .channelList } })
// Both join Lobby (channel 1) so voice relays between them.
XCTAssertEqual(a.joinChannel(1), .ok)
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .joinResult } },
"A did not receive .joinResult")
XCTAssertEqual(b.joinChannel(1), .ok)
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .joinResult } },
"B did not receive .joinResult")
// UDP binding handshake is async; give it a moment.
Thread.sleep(forTimeInterval: 0.5)
// A publishes a MIC stream.
let (startResult, streamId) = a.startStream(StreamDescriptor(kind: .mic, label: "mix-test-mic"))
XCTAssertEqual(startResult, .ok)
XCTAssertNotEqual(streamId, 0)
// B sees A's stream.
XCTAssertTrue(waitFor(timeout: 5) {
eventsB.contains { $0.type == .streamStarted && $0.streamId == streamId }
}, "B did not see A's .streamStarted")
// Resolve A's user id from B's user list.
var aUid: UInt32 = 0
XCTAssertTrue(waitFor(timeout: 3) {
aUid = b.listUsers().first { $0.nickname == "SwiftMixA" }?.id ?? 0
return aUid != 0
}, "could not resolve A's user id on B")
XCTAssertNotEqual(aUid, 0)
// B can enumerate A's stream.
XCTAssertTrue(waitFor(timeout: 3) {
b.listUserStreams(aUid).contains { $0.id == streamId }
}, "B could not enumerate A's stream")
let bStreams = b.listUserStreams(aUid)
XCTAssertTrue(bStreams.contains { $0.id == streamId && $0.kind == .mic })
// Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off).
let (r0, st0) = b.getRemoteStream(userId: aUid, streamId: streamId)
XCTAssertEqual(r0, .ok)
XCTAssertNotNil(st0)
XCTAssertEqual(st0?.gain, 1.0)
XCTAssertFalse(st0?.muted ?? true)
XCTAssertFalse(st0?.noiseReduction ?? true)
// B turns A down to 0.5×, mutes, enables NR then reads it back.
XCTAssertEqual(b.setRemoteStream(userId: aUid, streamId: streamId,
gain: 0.5, muted: true, noiseReduction: true), .ok)
let (r1, st1) = b.getRemoteStream(userId: aUid, streamId: streamId)
XCTAssertEqual(r1, .ok)
XCTAssertNotNil(st1)
XCTAssertEqual(st1?.gain, 0.5)
XCTAssertTrue(st1?.muted ?? false)
XCTAssertTrue(st1?.noiseReduction ?? false)
// Unknown stream id on a known user .invalidArg.
let (rBad, stBad) = b.getRemoteStream(userId: aUid, streamId: 0xDEADBEEF)
XCTAssertEqual(rBad, .invalidArg)
XCTAssertNil(stBad)
a.disconnect()
b.disconnect()
}
}
+14
View File
@@ -0,0 +1,14 @@
<Solution>
<Folder Name="/apps/">
<Project Path="VoiceCat.Mac/VoiceCat.Mac.csproj" />
<Project Path="VoiceCat.iOS/VoiceCat.iOS.csproj" />
</Folder>
<Folder Name="/managed/">
<Project Path="../../src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<Project Path="../../src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<Project Path="../../src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
<Project Path="../../src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
<Project Path="../../src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
<Project Path="../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,166 @@
using AppKit;
using CoreGraphics;
using VoiceCat.Core;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
namespace VoiceCat.Mac;
internal sealed class AdministrationWindowController : NSWindowController
{
private readonly VoiceCatClient client;
private readonly uint selfId;
private readonly NSPopUpButton users = new(new CGRect(25, 335, 310, 28), false);
private readonly NSPopUpButton channels = new(new CGRect(25, 175, 310, 28), false);
private readonly NSTextField status = NSTextField.CreateLabel("");
internal AdministrationWindowController(VoiceCatClient client, uint selfId) : base(new NSWindow(new CGRect(0, 0, 560, 420),
NSWindowStyle.Titled | NSWindowStyle.Closable, NSBackingStore.Buffered, false))
{
this.client = client; this.selfId = selfId; Window!.Title = "VoiceCat Administration"; Window.Center();
NSView view = Window.ContentView!;
AddLabel(view, "User moderation", 25, 375); view.AddSubview(users);
AddButton(view, "Move…", 350, 333, Move); AddButton(view, "Kick…", 440, 333, Kick);
AddButton(view, "Ban…", 350, 293, Ban); AddButton(view, "Permissions…", 440, 293, Permissions);
AddButton(view, "Server mute", 350, 253, ServerMute); AddButton(view, "Server deafen", 440, 253, ServerDeafen);
AddButton(view, "Audio tuning…", 25, 253, Tune);
AddLabel(view, "Channel administration", 25, 215); view.AddSubview(channels);
AddButton(view, "Create…", 350, 173, CreateChannel); AddButton(view, "Edit…", 440, 173, EditChannel);
AddButton(view, "Delete", 350, 133, DeleteChannel); AddButton(view, "Accounts…", 440, 133, Accounts);
var refresh = new NSButton(new CGRect(25, 75, 100, 32)) { Title = "Refresh" }; refresh.Activated += (_, _) => Refresh(); view.AddSubview(refresh);
status.Frame = new CGRect(25, 25, 510, 35); status.AccessibilityLabel = "Administration result"; view.AddSubview(status);
((INSAccessibility)users).AccessibilityLabel = "User to administer"; ((INSAccessibility)channels).AccessibilityLabel = "Channel to administer";
Refresh(); ApplyPermissions();
}
private void Refresh()
{
uint selectedUser = Selected(users), selectedChannel = Selected(channels);
users.RemoveAllItems(); foreach (User user in client.Users.Where(user => user.Id != selfId).OrderBy(user => user.Nickname)) Add(users, user.Nickname, user.Id);
channels.RemoveAllItems(); foreach (Channel channel in client.Channels.OrderBy(channel => channel.Order).ThenBy(channel => channel.Name)) Add(channels, channel.Name, channel.Id);
Select(users, selectedUser); Select(channels, selectedChannel);
}
private void ApplyPermissions()
{
Permissions permissions = client.Permissions;
status.StringValue = permissions.IsAdmin ? "Connected as administrator" : "Only actions allowed by your server permissions will succeed.";
}
private async void Move(object? sender, EventArgs args)
{
uint userId = Selected(users); if (userId == 0) return;
string[] names = client.Channels.Select(channel => channel.Name).ToArray(); int index = PromptChoice("Move user", "Destination channel", names); if (index < 0) return;
Show(await client.MoveUserAsync(userId, client.Channels[index].Id));
}
private async void Kick(object? sender, EventArgs args) { uint id = Selected(users); string? reason = PromptText("Kick user", "Reason", "Kicked by moderator"); if (id != 0 && reason is not null) Show(await client.KickUserAsync(id, reason)); }
private async void Ban(object? sender, EventArgs args)
{
uint id = Selected(users); if (id == 0) return;
string? reason = PromptText("Ban user", "Reason (append @1h, @1d, or @permanent)", "Banned by moderator @1d"); if (reason is null) return;
ulong expiry = 0; string lowered = reason.ToLowerInvariant();
if (lowered.EndsWith("@1h")) expiry = checked((ulong)DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds());
else if (lowered.EndsWith("@1d")) expiry = checked((ulong)DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds());
Show(await client.BanUserAsync(id, reason.Replace("@1h", "", StringComparison.OrdinalIgnoreCase).Replace("@1d", "", StringComparison.OrdinalIgnoreCase).Replace("@permanent", "", StringComparison.OrdinalIgnoreCase).Trim(), expiry));
}
private async void Permissions(object? sender, EventArgs args)
{
uint id = Selected(users); if (id == 0) return;
Permissions? permissions = PermissionEditor.Run(); if (permissions is not null) Show(await client.SetPermissionsAsync(id, permissions));
}
private async void ServerMute(object? sender, EventArgs args) { User? user = User(); if (user is not null) Show(await client.SetServerMuteAsync(user.Id, !user.ServerMuted, user.ServerDeafened)); }
private async void ServerDeafen(object? sender, EventArgs args) { User? user = User(); if (user is not null) Show(await client.SetServerMuteAsync(user.Id, user.ServerMuted, !user.ServerDeafened)); }
private void Tune(object? sender, EventArgs args) { User? user = User(); if (user is not null) PerUserTuning.Run(client, user); }
private async void CreateChannel(object? sender, EventArgs args) { ChannelEdit? edit = ChannelEditor.Run(null, client.Channels, client.SupportsAdaptivePacketLoss); if (edit is not null) { Show(await client.CreateChannelAsync(edit.Channel, edit.Password)); Refresh(); } }
private async void EditChannel(object? sender, EventArgs args) { Channel? channel = Channel(); if (channel is null) return; ChannelEdit? edit = ChannelEditor.Run(channel, client.Channels, client.SupportsAdaptivePacketLoss); if (edit is not null) { Show(await client.EditChannelAsync(edit.Channel, edit.Password)); Refresh(); } }
private async void DeleteChannel(object? sender, EventArgs args)
{
Channel? channel = Channel(); if (channel is null || channel.Id == 1) return;
if (!Confirm("Delete channel?", channel.Name)) return; Show(await client.DeleteChannelAsync(channel.Id)); Refresh();
}
private async void Accounts(object? sender, EventArgs args) => await AccountEditor.RunAsync(client);
private User? User() => client.Users.FirstOrDefault(user => user.Id == Selected(users));
private Channel? Channel() => client.Channels.FirstOrDefault(channel => channel.Id == Selected(channels));
private void Show(GenericResult result) { status.StringValue = result.Ok ? result.Message.Length == 0 ? "Operation completed." : result.Message : result.Message; Refresh(); }
private static void Add(NSPopUpButton picker, string title, uint id) { picker.AddItem(title); picker.LastItem!.RepresentedObject = new Foundation.NSString(id.ToString()); }
private static uint Selected(NSPopUpButton picker) => uint.TryParse(picker.SelectedItem?.RepresentedObject?.ToString(), out uint id) ? id : 0;
private static void Select(NSPopUpButton picker, uint id) { for (nint i = 0; i < picker.ItemCount; i++) if (picker.ItemAtIndex(i)?.RepresentedObject?.ToString() == id.ToString()) { picker.SelectItem(i); break; } }
private static void AddLabel(NSView view, string value, double x, double y) { NSTextField label = NSTextField.CreateLabel(value); label.Frame = new CGRect(x, y, 300, 22); label.Font = NSFont.BoldSystemFontOfSize(13)!; view.AddSubview(label); }
private static void AddButton(NSView view, string title, double x, double y, EventHandler handler) { var button = new NSButton(new CGRect(x, y, 95, 32)) { Title = title }; button.Activated += handler; view.AddSubview(button); }
internal static string? PromptText(string title, string prompt, string value = "", bool secure = false)
{
NSTextField field = secure ? new NSSecureTextField(new CGRect(0, 0, 340, 26)) : new NSTextField(new CGRect(0, 0, 340, 26)); field.StringValue = value;
var alert = new NSAlert { MessageText = title, InformativeText = prompt, AccessoryView = field }; alert.AddButton("OK"); alert.AddButton("Cancel");
return alert.RunModal() == 1000 ? field.StringValue : null;
}
internal static int PromptChoice(string title, string prompt, string[] values)
{
var picker = new NSPopUpButton(new CGRect(0, 0, 340, 28), false); picker.AddItems(values);
var alert = new NSAlert { MessageText = title, InformativeText = prompt, AccessoryView = picker }; alert.AddButton("OK"); alert.AddButton("Cancel");
return alert.RunModal() == 1000 ? checked((int)picker.IndexOfSelectedItem) : -1;
}
internal static bool Confirm(string title, string detail) { var alert = new NSAlert { MessageText = title, InformativeText = detail, AlertStyle = NSAlertStyle.Warning }; alert.AddButton("Continue"); alert.AddButton("Cancel"); return alert.RunModal() == 1000; }
}
internal sealed record ChannelEdit(Channel Channel, string Password);
internal static class PermissionEditor
{
internal static Permissions? Run()
{
string[] labels = ["Create temporary channels", "Kick users", "Ban users", "Move users", "Manage accounts", "Full administrator"];
var view = new NSView(new CGRect(0, 0, 330, 160)); var checks = new List<NSButton>();
for (int i = 0; i < labels.Length; i++) { NSButton check = NSButton.CreateCheckbox(labels[i], () => { }); check.Frame = new CGRect(0, 135 - i * 25, 320, 22); view.AddSubview(check); checks.Add(check); }
var alert = new NSAlert { MessageText = "Set permissions", AccessoryView = view }; alert.AddButton("Save"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return null;
return new() { CanCreateTempChannel = On(0), CanKick = On(1), CanBan = On(2), CanMoveUsers = On(3), CanAdminAccounts = On(4), IsAdmin = On(5) };
bool On(int i) => checks[i].State == NSCellStateValue.On;
}
}
internal static class PerUserTuning
{
internal static void Run(VoiceCatClient client, User user)
{
if (user.Streams.Count == 0) { AdministrationWindowController.PromptText("Audio tuning", "This user has no active streams."); return; }
var stream = new NSPopUpButton(new CGRect(0, 95, 340, 28), false); stream.AddItems(user.Streams.Select(value => $"{value.Label} ({value.Kind})").ToArray());
var gain = new NSSlider(new CGRect(0, 60, 340, 24)) { MinValue = 0, MaxValue = 400, DoubleValue = 100 };
var muted = NSButton.CreateCheckbox("Mute this stream", () => { }); muted.Frame = new CGRect(0, 30, 160, 24);
var noise = NSButton.CreateCheckbox("Receive noise reduction", () => { }); noise.Frame = new CGRect(170, 30, 170, 24);
var view = new NSView(new CGRect(0, 0, 340, 130)); view.AddSubview(stream); view.AddSubview(gain); view.AddSubview(muted); view.AddSubview(noise);
void LoadSelected()
{
if (stream.IndexOfSelectedItem < 0) return;
StreamInfo selected = user.Streams[checked((int)stream.IndexOfSelectedItem)];
(float Gain, bool Muted, bool NoiseReduction)? state = client.Audio.GetRemotePlayback(user.Id, selected.StreamId);
gain.DoubleValue = (state?.Gain ?? 1) * 100; muted.State = state?.Muted == true ? NSCellStateValue.On : NSCellStateValue.Off;
noise.State = state?.NoiseReduction == true ? NSCellStateValue.On : NSCellStateValue.Off;
}
stream.Activated += (_, _) => LoadSelected(); LoadSelected();
var alert = new NSAlert { MessageText = $"Audio — {user.Nickname}", InformativeText = "Stream, volume, mute, and receive noise reduction", AccessoryView = view };
alert.AddButton("Apply"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return;
StreamInfo selected = user.Streams[checked((int)stream.IndexOfSelectedItem)]; client.Audio.SetRemotePlayback(user.Id, selected.StreamId, (float)gain.DoubleValue / 100,
muted.State == NSCellStateValue.On, noise.State == NSCellStateValue.On);
}
}
internal static class AccountEditor
{
internal static async Task RunAsync(VoiceCatClient client)
{
IReadOnlyList<AccountEntry> accounts = await client.ListAccountsAsync();
string[] actions = ["Create account", "Reset password", "Delete account", "Close"];
int action = AdministrationWindowController.PromptChoice("Server accounts", string.Join("\n", accounts.Select(a => $"{a.Username}{(a.IsAdmin ? " (admin)" : "")}")), actions);
if (action is < 0 or 3) return;
if (action == 0)
{
string? name = AdministrationWindowController.PromptText("Create account", "Username"); if (string.IsNullOrWhiteSpace(name)) return;
string? password = AdministrationWindowController.PromptText("Create account", "Password", secure: true); if (!string.IsNullOrEmpty(password)) await client.CreateAccountAsync(name.Trim(), password);
return;
}
int selected = AdministrationWindowController.PromptChoice(action == 1 ? "Reset password" : "Delete account", "Account", accounts.Select(a => a.Username).ToArray()); if (selected < 0) return;
if (action == 1) { string? password = AdministrationWindowController.PromptText("Reset password", "New password", secure: true); if (!string.IsNullOrEmpty(password)) await client.ResetPasswordAsync(accounts[selected].Username, password); }
else if (AdministrationWindowController.Confirm("Delete account?", accounts[selected].Username)) await client.DeleteAccountAsync(accounts[selected].Username);
}
}
+22
View File
@@ -0,0 +1,22 @@
using AppKit;
using Foundation;
namespace VoiceCat.Mac;
internal sealed class AppDelegate : NSApplicationDelegate
{
private ConnectWindowController? connect;
public override void DidFinishLaunching(NSNotification notification)
{
NSApplication.SharedApplication.ActivationPolicy = NSApplicationActivationPolicy.Regular;
BuildMenu();
connect = new(); connect.ShowWindow(this);
}
public override bool ApplicationShouldTerminateAfterLastWindowClosed(NSApplication sender) => true;
private static void BuildMenu()
{
var menu = new NSMenu(); var root = new NSMenuItem(); menu.AddItem(root);
var application = new NSMenu(); application.AddItem(new NSMenuItem("Quit VoiceCat", "q", (_, _) => NSApplication.SharedApplication.Terminate(null)));
root.Submenu = application; NSApplication.SharedApplication.MainMenu = menu;
}
}
@@ -0,0 +1,68 @@
using AppKit;
using CoreGraphics;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
namespace VoiceCat.Mac;
internal static class ChannelEditor
{
internal static ChannelEdit? Run(Channel? existing, IReadOnlyList<Channel> channels, bool supportsAdaptivePacketLoss)
{
var view = new NSView(new CGRect(0, 0, 520, 500));
var name = Field(existing?.Name ?? "", 360); var topic = Field(existing?.Topic ?? "", 330);
var parent = Picker(channels.Select(channel => channel.Name).Prepend("No parent").ToArray(), 300);
int parentIndex = existing is null ? 0 : channels.ToList().FindIndex(channel => channel.Id == existing.ParentId) + 1; parent.SelectItem(Math.Max(0, parentIndex));
var type = Picker(["Permanent", "Temporary"], 270); type.SelectItem(existing?.Type == ChannelType.ChannelTemporary ? 1 : 0);
var maximum = Field((existing?.MaxUsers ?? 0).ToString(), 240); var order = Field((existing?.Order ?? 0).ToString(), 210);
var password = new NSSecureTextField(new CGRect(155, 180, 345, 24)) { PlaceholderString = existing?.PasswordProtected == true ? "Leave blank to preserve" : "Optional" };
var mode = Picker(["Mono", "Stereo"], 150); mode.SelectItem(existing?.Audio?.Mode == ChannelMode.ModeStereo ? 1 : 0);
var rate = Picker(["8000", "12000", "16000", "24000", "48000"], 120); Select(rate, (existing?.Audio?.SampleRate ?? 48000).ToString());
var frame = Picker(["5", "10", "20", "40", "60"], 90); Select(frame, (existing?.Audio?.FrameMs ?? 20).ToString());
var application = Picker(["VoIP", "Audio", "Low delay"], 60); application.SelectItem((int)(existing?.Audio?.Application ?? OpusApplication.OpusVoip));
var bitrate = Field((existing?.Audio?.BitrateBps ?? 64000).ToString(), 30);
Add(view, "Name", name, 360); Add(view, "Topic", topic, 330); Add(view, "Parent", parent, 300); Add(view, "Type", type, 270);
Add(view, "Maximum users (0=none)", maximum, 240); Add(view, "Sort order", order, 210); Add(view, "Password", password, 180);
Add(view, "Mode", mode, 150); Add(view, "Sample rate", rate, 120); Add(view, "Frame ms", frame, 90); Add(view, "Application", application, 60); Add(view, "Bitrate", bitrate, 30);
var advanced = new NSView(new CGRect(0, 0, 520, 175));
var automaticLoss = Check("Automatic packet loss", existing?.Audio?.PacketLossMode != PacketLossMode.PacketLossManual, 155, 140);
automaticLoss.Frame = new CGRect(155, 140, 200, 24);
automaticLoss.Enabled = supportsAdaptivePacketLoss;
var lossSpeed = Picker(["Fast", "Balanced", "Stable"], 105);
lossSpeed.SelectItem(existing?.Audio?.PacketLossMode switch { PacketLossMode.PacketLossAutoFast => 0, PacketLossMode.PacketLossAutoStable => 2, _ => 1 });
var loss = Field((existing?.Audio?.ExpectedPacketLoss ?? 5).ToString(), 70); loss.Frame = new CGRect(155, 70, 100, 24);
var complexity = Field((existing?.Audio?.Complexity ?? 10).ToString(), 40); complexity.Frame = new CGRect(155, 40, 100, 24);
var fec = Check("In-band FEC", existing?.Audio?.Fec ?? true, 0, 10); var dtx = Check("DTX", existing?.Audio?.Dtx ?? true, 130, 10);
var dred = Check("Deep redundancy", existing?.Audio?.Dred ?? false, 230, 10);
void UpdateLossControls() { loss.Enabled = !On(automaticLoss); lossSpeed.Enabled = supportsAdaptivePacketLoss && On(automaticLoss); }
automaticLoss.Activated += (_, _) => UpdateLossControls(); UpdateLossControls();
advanced.AddSubview(automaticLoss); Add(advanced, "Adaptation speed", lossSpeed, 105); Add(advanced, "Packet loss %", loss, 70); Add(advanced, "Complexity 010", complexity, 40);
advanced.AddSubview(fec); advanced.AddSubview(dtx); advanced.AddSubview(dred);
var container = new NSView(new CGRect(0, 0, 520, 690)); view.Frame = new CGRect(0, 180, 520, 500); container.AddSubview(view); container.AddSubview(advanced);
var alert = new NSAlert { MessageText = existing is null ? "Create channel" : "Edit channel", AccessoryView = container };
alert.AddButton(existing is null ? "Create" : "Save"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return null;
if (string.IsNullOrWhiteSpace(name.StringValue) || !uint.TryParse(maximum.StringValue, out uint max) || !int.TryParse(order.StringValue, out int sort) ||
!uint.TryParse(rate.TitleOfSelectedItem, out uint sampleRate) || !uint.TryParse(frame.TitleOfSelectedItem, out uint frameMs) ||
!uint.TryParse(bitrate.StringValue, out uint bitrateValue) || !uint.TryParse(loss.StringValue, out uint packetLoss) ||
!uint.TryParse(complexity.StringValue, out uint complexityValue) || packetLoss > 100 || complexityValue > 10) return null;
uint parentId = parent.IndexOfSelectedItem <= 0 ? 0 : channels[checked((int)parent.IndexOfSelectedItem - 1)].Id;
return new(new Channel
{
Id = existing?.Id ?? 0, ParentId = parentId, Name = name.StringValue.Trim(), Topic = topic.StringValue.Trim(), MaxUsers = max, Order = sort,
Type = type.IndexOfSelectedItem == 1 ? ChannelType.ChannelTemporary : ChannelType.ChannelPermanent,
Audio = new AudioConfig { Codec = 0, Mode = mode.IndexOfSelectedItem == 1 ? ChannelMode.ModeStereo : ChannelMode.ModeMono,
SampleRate = sampleRate, FrameMs = frameMs, Application = (OpusApplication)(int)application.IndexOfSelectedItem, BitrateBps = bitrateValue,
Fec = On(fec), Dtx = On(dtx), Dred = On(dred), ExpectedPacketLoss = packetLoss, Complexity = complexityValue,
PacketLossMode = On(automaticLoss) ? lossSpeed.IndexOfSelectedItem switch
{ 0 => PacketLossMode.PacketLossAutoFast, 2 => PacketLossMode.PacketLossAutoStable, _ => PacketLossMode.PacketLossAutoBalanced } : PacketLossMode.PacketLossManual }
}, password.StringValue);
}
private static NSTextField Field(string value, double y) => new(new CGRect(155, y, 345, 24)) { StringValue = value };
private static NSPopUpButton Picker(string[] values, double y) { var picker = new NSPopUpButton(new CGRect(155, y, 345, 26), false); picker.AddItems(values); return picker; }
private static NSButton Check(string title, bool value, double x, double y) { NSButton check = NSButton.CreateCheckbox(title, () => { }); check.Frame = new CGRect(x, y, 125, 24); check.State = value ? NSCellStateValue.On : NSCellStateValue.Off; return check; }
private static bool On(NSButton value) => value.State == NSCellStateValue.On;
private static void Add(NSView view, string label, NSView control, double y) { var text = NSTextField.CreateLabel(label); text.Frame = new CGRect(0, y + 2, 145, 20); view.AddSubview(text); view.AddSubview(control); ((INSAccessibility)control).AccessibilityLabel = label; }
private static void Select(NSPopUpButton picker, string value) { for (nint i = 0; i < picker.ItemCount; i++) if (picker.ItemTitle(i) == value) { picker.SelectItem(i); return; } }
}
@@ -0,0 +1,217 @@
using AppKit;
using CoreGraphics;
using VoiceCat.Core;
using VoiceCat.Crypto;
namespace VoiceCat.Mac;
internal sealed class ConnectWindowController : NSWindowController
{
private static readonly string SupportDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VoiceCat");
private readonly ServerProfileStore profileStore = new(Path.Combine(SupportDirectory, "servers.json"));
private readonly MacKeychainPasswordStore keychain = new();
private readonly NSPopUpButton profiles = new(new CGRect(120, 315, 330, 28), false);
private readonly NSTextField host = new(new CGRect(120, 270, 330, 26)) { StringValue = "127.0.0.1:8384", PlaceholderString = "Host:port" };
private readonly NSPopUpButton authentication = new(new CGRect(120, 225, 180, 28), false);
private readonly NSTextField identity = new(new CGRect(120, 180, 330, 26)) { StringValue = Environment.UserName };
private readonly NSTextField identityLabel;
private readonly NSSecureTextField password = new(new CGRect(120, 135, 330, 26)) { PlaceholderString = "Password" };
private readonly NSButton rememberPassword = NSButton.CreateCheckbox("Remember in Keychain", () => { });
private readonly NSTextField status = NSTextField.CreateLabel("Ready to connect");
private readonly NSButton save = new(new CGRect(120, 75, 90, 32)) { Title = "Save" };
private readonly NSButton remove = new(new CGRect(220, 75, 90, 32)) { Title = "Remove" };
private readonly NSButton connect = new(new CGRect(360, 75, 90, 32)) { Title = "Connect", BezelStyle = NSBezelStyle.Rounded };
private List<ServerProfile> saved = [];
private Guid? editingId;
private VoiceCatClient? client;
private MainWindowController? main;
internal ConnectWindowController() : base(new NSWindow(new CGRect(0, 0, 570, 390), NSWindowStyle.Titled | NSWindowStyle.Closable,
NSBackingStore.Buffered, false))
{
Window!.Title = "Connect to VoiceCat"; Window.Center();
var view = Window.ContentView!;
AddLabel(view, "Saved server", 25, 320); AddLabel(view, "Server", 25, 275); AddLabel(view, "Authentication", 25, 230);
identityLabel = AddLabel(view, "Nickname", 25, 185); AddLabel(view, "Password", 25, 140);
authentication.AddItem("Guest"); authentication.AddItem("Account"); authentication.Activated += AuthenticationChanged;
profiles.Activated += ProfileChanged;
((INSAccessibility)profiles).AccessibilityLabel = "Saved servers";
((INSAccessibility)host).AccessibilityLabel = "Server hostname and port";
((INSAccessibility)authentication).AccessibilityLabel = "Authentication mode";
((INSAccessibility)identity).AccessibilityLabel = "Guest nickname";
((INSAccessibility)password).AccessibilityLabel = "Account password";
rememberPassword.Frame = new CGRect(310, 225, 180, 24);
((INSAccessibility)rememberPassword).AccessibilityLabel = "Remember account password in Keychain";
view.AddSubview(profiles); view.AddSubview(host); view.AddSubview(authentication); view.AddSubview(identity); view.AddSubview(password); view.AddSubview(rememberPassword);
status.Frame = new CGRect(25, 30, 520, 24); status.AccessibilityLabel = "Connection status"; view.AddSubview(status);
((INSAccessibility)save).AccessibilityLabel = "Save server profile"; save.Activated += SaveProfile; view.AddSubview(save);
((INSAccessibility)remove).AccessibilityLabel = "Remove server profile"; remove.Activated += RemoveProfile; view.AddSubview(remove);
((INSAccessibility)connect).AccessibilityLabel = "Connect to server"; connect.Activated += Connect; view.AddSubview(connect);
Window.DefaultButtonCell = connect.Cell;
ReloadProfiles(); UpdateAuthentication();
}
private static NSTextField AddLabel(NSView view, string text, double x, double y)
{
var label = NSTextField.CreateLabel(text); label.Frame = new CGRect(x, y, 90, 20); view.AddSubview(label); return label;
}
private void ReloadProfiles(Guid? select = null)
{
saved = profileStore.Load().ToList();
profiles.RemoveAllItems(); profiles.AddItem("New server…");
foreach (ServerProfile profile in saved) profiles.AddItem(profile.DisplayName);
int index = select is null ? (saved.Count == 0 ? 0 : 1) : saved.FindIndex(item => item.Id == select) + 1;
profiles.SelectItem(Math.Max(0, index));
ApplySelectedProfile();
}
private void ProfileChanged(object? sender, EventArgs args) => ApplySelectedProfile();
private void ApplySelectedProfile()
{
int index = checked((int)profiles.IndexOfSelectedItem) - 1;
if (index < 0 || index >= saved.Count)
{
editingId = null; remove.Enabled = false; password.StringValue = "";
rememberPassword.State = NSCellStateValue.Off; UpdateAuthentication(); return;
}
ServerProfile profile = saved[index]; editingId = profile.Id; remove.Enabled = true;
host.StringValue = FormatEndpoint(profile.Host, profile.Port);
authentication.SelectItem(profile.Authentication == ServerAuthentication.Guest ? 0 : 1);
identity.StringValue = profile.Authentication == ServerAuthentication.Guest ? profile.Nickname ?? Environment.UserName : profile.Username ?? "";
string? storedPassword = profile.Authentication == ServerAuthentication.Account
? keychain.Load(profile.Id) ?? keychain.LoadLegacy(profile.LegacyKeychainTag) : null;
password.StringValue = storedPassword ?? "";
rememberPassword.State = storedPassword is null ? NSCellStateValue.Off : NSCellStateValue.On;
UpdateAuthentication();
}
private void AuthenticationChanged(object? sender, EventArgs args) => UpdateAuthentication();
private void UpdateAuthentication()
{
bool account = authentication.IndexOfSelectedItem == 1;
identityLabel.StringValue = account ? "Username" : "Nickname";
identity.PlaceholderString = account ? "Username" : "Nickname";
((INSAccessibility)identity).AccessibilityLabel = account ? "Account username" : "Guest nickname";
password.Enabled = account;
rememberPassword.Enabled = account && editingId is not null;
}
private void SaveProfile(object? sender, EventArgs args)
{
try
{
ServerProfile profile = ReadProfile();
string pendingPassword = password.StringValue;
bool shouldRemember = rememberPassword.State == NSCellStateValue.On;
int index = saved.FindIndex(item => item.Id == profile.Id);
if (index < 0) saved.Add(profile); else saved[index] = profile;
if (profile.Authentication == ServerAuthentication.Guest || !shouldRemember) keychain.Remove(profile.Id);
profileStore.Save(saved); ReloadProfiles(profile.Id);
if (profile.Authentication == ServerAuthentication.Account && shouldRemember)
{
password.StringValue = pendingPassword;
rememberPassword.State = NSCellStateValue.On;
}
status.StringValue = "Server profile saved. Passwords are stored only after successful login.";
}
catch (Exception exception) { status.StringValue = exception.Message; }
}
private void RemoveProfile(object? sender, EventArgs args)
{
if (editingId is not { } id) return;
ServerProfile? profile = saved.FirstOrDefault(item => item.Id == id); if (profile is null) return;
var alert = new NSAlert { MessageText = "Remove server?", InformativeText = profile.DisplayName, AlertStyle = NSAlertStyle.Warning };
alert.AddButton("Remove"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return;
keychain.Remove(profile.Id); saved.Remove(profile); profileStore.Save(saved); editingId = null; ReloadProfiles(); status.StringValue = "Server profile removed.";
}
private async void Connect(object? sender, EventArgs args)
{
if (client is not null) return;
try
{
SetBusy(true); status.StringValue = "Connecting…";
ServerProfile profile = ReadProfile();
string pins = Path.Combine(SupportDirectory, "tofu_pins.txt");
client = new("VoiceCat macOS", "0.1.0", pins);
await client.ConnectAsync(profile.Host, profile.Port, ConfirmIdentity);
string accountPassword = password.StringValue;
if (profile.Authentication == ServerAuthentication.Account && accountPassword.Length == 0)
accountPassword = keychain.Load(profile.Id) ?? keychain.LoadLegacy(profile.LegacyKeychainTag) ?? "";
if (profile.Authentication == ServerAuthentication.Account && accountPassword.Length == 0) throw new ArgumentException("Enter the account password.");
password.StringValue = "";
var result = profile.Authentication == ServerAuthentication.Guest
? await client.AuthenticateGuestAsync(profile.Nickname ?? Environment.UserName)
: await client.AuthenticateUserAsync(profile.Username!, accountPassword);
if (!result.Ok) throw new InvalidOperationException(result.Error);
try
{
if (profile.Authentication == ServerAuthentication.Account && rememberPassword.State == NSCellStateValue.On) keychain.Save(profile.Id, accountPassword);
else keychain.Remove(profile.Id);
}
catch (Exception exception) { ShowKeychainWarning(exception.Message); }
main = new(client, result.Self.Id, result.Self.Nickname);
client = null; main.ShowWindow(this); Window.Close();
}
catch (Exception exception)
{
if (client is not null) await client.DisposeAsync(); client = null;
status.StringValue = exception.Message; SetBusy(false);
}
}
private ServerProfile ReadProfile()
{
(string serverHost, ushort port) = ParseEndpoint(host.StringValue);
bool account = authentication.IndexOfSelectedItem == 1;
string name = identity.StringValue.Trim();
return ServerProfile.Create(serverHost, port, account ? ServerAuthentication.Account : ServerAuthentication.Guest,
username: account ? name : null, nickname: account ? null : name, id: editingId);
}
private void SetBusy(bool busy)
{
profiles.Enabled = host.Enabled = authentication.Enabled = identity.Enabled = save.Enabled = connect.Enabled = !busy;
remove.Enabled = !busy && editingId is not null;
password.Enabled = !busy && authentication.IndexOfSelectedItem == 1;
rememberPassword.Enabled = !busy && authentication.IndexOfSelectedItem == 1 && editingId is not null;
}
private ValueTask<bool> ConfirmIdentity(ServerIdentityChallenge challenge, CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
NSApplication.SharedApplication.InvokeOnMainThread(() =>
{
var alert = new NSAlert { MessageText = challenge.Status == TofuStatus.FirstConnect ? "Trust this VoiceCat server?" : "Server identity changed",
InformativeText = $"{challenge.Host}:{challenge.Port}\n\nCertificate SHA-256:\n{challenge.CertificateFingerprint}", AlertStyle = challenge.Status == TofuStatus.Mismatch ? NSAlertStyle.Critical : NSAlertStyle.Informational };
alert.AddButton("Trust and connect"); alert.AddButton("Cancel"); completion.TrySetResult(alert.RunModal() == 1000);
});
return new(completion.Task.WaitAsync(cancellationToken));
}
private static void ShowKeychainWarning(string message)
{
var alert = new NSAlert
{
MessageText = "Password storage failed",
InformativeText = message + " You are still signed in, but the Keychain setting was not changed.",
AlertStyle = NSAlertStyle.Warning
};
alert.RunModal();
}
private static (string Host, ushort Port) ParseEndpoint(string value)
{
int separator = value.LastIndexOf(':');
if (separator <= 0 || !ushort.TryParse(value[(separator + 1)..], out ushort port) || port == 0) throw new ArgumentException("Enter a server as host:port.");
string parsedHost = value[..separator].Trim().Trim('[', ']');
if (parsedHost.Length == 0) throw new ArgumentException("Enter a server as host:port.");
return (parsedHost, port);
}
private static string FormatEndpoint(string serverHost, ushort port) => serverHost.Contains(':') ? $"[{serverHost}]:{port}" : $"{serverHost}:{port}";
}
@@ -0,0 +1,113 @@
using System.Runtime.InteropServices;
using Foundation;
using ObjCRuntime;
using VoiceCat.Audio;
namespace VoiceCat.Mac;
internal static class CoreAudioDevices
{
private const string CoreAudio = "/System/Library/Frameworks/CoreAudio.framework/CoreAudio";
private const uint SystemObject = 1;
private const uint Devices = 0x64657623; // 'dev#'
private const uint DefaultInput = 0x64496e20; // 'dIn '
private const uint DefaultOutput = 0x644f7574; // 'dOut'
private const uint StreamConfiguration = 0x736c6179; // 'slay'
private const uint ObjectName = 0x6c6e616d; // 'lnam'
private const uint ScopeGlobal = 0x676c6f62; // 'glob'
private const uint ScopeInput = 0x696e7074; // 'inpt'
private const uint ScopeOutput = 0x6f757470; // 'outp'
[StructLayout(LayoutKind.Sequential)]
private struct PropertyAddress(uint selector, uint scope)
{
public uint Selector = selector;
public uint Scope = scope;
public uint Element;
}
[DllImport(CoreAudio)]
private static extern int AudioObjectGetPropertyDataSize(uint objectId, ref PropertyAddress address,
uint qualifierDataSize, nint qualifierData, out uint dataSize);
[DllImport(CoreAudio)]
private static extern int AudioObjectGetPropertyData(uint objectId, ref PropertyAddress address,
uint qualifierDataSize, nint qualifierData, ref uint dataSize, nint data);
internal static IReadOnlyList<AudioDeviceInfo> List(bool input)
{
uint defaultId = ReadUInt(SystemObject, input ? DefaultInput : DefaultOutput, ScopeGlobal);
var devices = new List<AudioDeviceInfo>();
foreach (uint id in ReadUIntArray(SystemObject, Devices, ScopeGlobal))
{
if (ChannelCount(id, input ? ScopeInput : ScopeOutput) == 0) continue;
string name = ReadString(id, ObjectName, ScopeGlobal) ?? $"Core Audio device {id}";
devices.Add(new(id.ToString(System.Globalization.CultureInfo.InvariantCulture), name, id == defaultId));
}
return devices.OrderByDescending(device => device.IsDefault).ThenBy(device => device.Name, StringComparer.CurrentCultureIgnoreCase).ToArray();
}
internal static uint ParseId(string? value)
{
if (!uint.TryParse(value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out uint id) || id == 0)
throw new ArgumentException("The selected Core Audio device is no longer available.", nameof(value));
return id;
}
private static uint ReadUInt(uint objectId, uint selector, uint scope)
{
var address = new PropertyAddress(selector, scope);
uint size = sizeof(uint);
nint memory = Marshal.AllocHGlobal(sizeof(uint));
try { return AudioObjectGetPropertyData(objectId, ref address, 0, 0, ref size, memory) == 0 ? unchecked((uint)Marshal.ReadInt32(memory)) : 0; }
finally { Marshal.FreeHGlobal(memory); }
}
private static uint[] ReadUIntArray(uint objectId, uint selector, uint scope)
{
var address = new PropertyAddress(selector, scope);
if (AudioObjectGetPropertyDataSize(objectId, ref address, 0, 0, out uint size) != 0 || size < sizeof(uint)) return [];
nint memory = Marshal.AllocHGlobal(checked((int)size));
try
{
if (AudioObjectGetPropertyData(objectId, ref address, 0, 0, ref size, memory) != 0) return [];
var result = new uint[size / sizeof(uint)];
for (int i = 0; i < result.Length; i++) result[i] = unchecked((uint)Marshal.ReadInt32(memory, i * sizeof(uint)));
return result;
}
finally { Marshal.FreeHGlobal(memory); }
}
private static int ChannelCount(uint deviceId, uint scope)
{
var address = new PropertyAddress(StreamConfiguration, scope);
if (AudioObjectGetPropertyDataSize(deviceId, ref address, 0, 0, out uint size) != 0 || size < 8) return 0;
nint memory = Marshal.AllocHGlobal(checked((int)size));
try
{
if (AudioObjectGetPropertyData(deviceId, ref address, 0, 0, ref size, memory) != 0) return 0;
int count = Marshal.ReadInt32(memory);
int first = IntPtr.Size == 8 ? 8 : 4;
int stride = IntPtr.Size == 8 ? 16 : 12;
int channels = 0;
for (int i = 0; i < count && first + i * stride + sizeof(uint) <= size; i++)
channels += Marshal.ReadInt32(memory, first + i * stride);
return channels;
}
finally { Marshal.FreeHGlobal(memory); }
}
private static string? ReadString(uint objectId, uint selector, uint scope)
{
var address = new PropertyAddress(selector, scope);
uint size = checked((uint)IntPtr.Size);
nint memory = Marshal.AllocHGlobal(IntPtr.Size);
try
{
if (AudioObjectGetPropertyData(objectId, ref address, 0, 0, ref size, memory) != 0) return null;
nint handle = Marshal.ReadIntPtr(memory);
return handle == 0 ? null : Runtime.GetNSObject<NSString>(handle, owns: false)?.ToString();
}
finally { Marshal.FreeHGlobal(memory); }
}
}
@@ -0,0 +1,49 @@
using AVFoundation;
using Foundation;
namespace VoiceCat.Mac;
internal enum SoundEvent { ChannelJoin, ChannelLeave, ChannelReceived, ChannelSent, PrivateReceived, PrivateSent, Login, Logout, ConnectionLost, VoiceOn, VoiceOff, VoiceStart, VoiceStop, PushToTalk }
internal sealed class EventFeedback : IDisposable
{
private readonly MacSettings settings;
private readonly AVSpeechSynthesizer speech = new();
private readonly Dictionary<SoundEvent, AVAudioPlayer> players = [];
internal EventFeedback(MacSettings settings) => this.settings = settings;
internal void Play(SoundEvent sound)
{
if (!settings.EventSounds || settings.EventVolume <= 0 ||
(sound is SoundEvent.VoiceStart or SoundEvent.VoiceStop && !settings.SelfTalkSounds) ||
(sound == SoundEvent.PushToTalk && !settings.PushToTalkSound)) return;
if (!players.TryGetValue(sound, out AVAudioPlayer? player))
{
string path = Path.Combine(NSBundle.MainBundle.ResourcePath ?? "", "Sounds", FileName(sound) + ".wav");
if (!File.Exists(path)) return;
player = AVAudioPlayer.FromUrl(NSUrl.FromFilename(path));
if (player is null) return;
player.PrepareToPlay(); players[sound] = player;
}
player.Volume = settings.EventVolume; player.CurrentTime = 0; player.Play();
}
internal void Speak(string text)
{
if (!settings.SpokenEvents || string.IsNullOrWhiteSpace(text)) return;
speech.SpeakUtterance(new AVSpeechUtterance(text.Trim()));
}
private static string FileName(SoundEvent value) => value switch
{
SoundEvent.ChannelJoin => "channel_join", SoundEvent.ChannelLeave => "channel_leave",
SoundEvent.ChannelReceived => "channel_recv", SoundEvent.ChannelSent => "channel_sent",
SoundEvent.PrivateReceived => "pm_recv", SoundEvent.PrivateSent => "pm_sent",
SoundEvent.Login => "login", SoundEvent.Logout => "logout", SoundEvent.ConnectionLost => "connection_lost",
SoundEvent.VoiceOn => "voice_on", SoundEvent.VoiceOff => "voice_off", SoundEvent.VoiceStart => "va_start",
SoundEvent.VoiceStop => "va_stop", _ => "ptt"
};
public void Dispose() { foreach (AVAudioPlayer player in players.Values) player.Dispose(); speech.Dispose(); }
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleDisplayName</key><string>VoiceCat</string>
<key>CFBundleIdentifier</key><string>net.iamtalon.voicecat</string>
<key>CFBundleName</key><string>VoiceCat</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleShortVersionString</key><string>0.1.0</string>
<key>LSMinimumSystemVersion</key><string>14.0</string>
<key>NSMicrophoneUsageDescription</key><string>VoiceCat uses the microphone when you join voice and enable a microphone stream.</string>
<key>NSScreenCaptureUsageDescription</key><string>VoiceCat uses ScreenCaptureKit only when you share desktop or application audio.</string>
<key>NSPrincipalClass</key><string>NSApplication</string>
</dict></plist>
@@ -0,0 +1,243 @@
using System.Runtime.InteropServices;
using AudioUnit;
using AVFoundation;
using Foundation;
using VoiceCat.Audio;
namespace VoiceCat.Mac;
internal sealed class MacAudioBackend : IAudioDeviceBackend
{
private Exception? failure;
internal Exception? Failure => Volatile.Read(ref failure);
public IReadOnlyList<AudioDeviceInfo> Enumerate(bool input) => CoreAudioDevices.List(input);
public IAudioCapture OpenCapture(string? deviceId, bool loopback, CapturePcmHandler pcm)
{
if (loopback) throw new NotSupportedException("Screen audio uses ScreenCaptureKit, not microphone capture.");
return new Capture(deviceId, pcm, ReportFailure);
}
public IAudioPlayback OpenPlayback(string? deviceId = null)
{
return new Playback(deviceId, ReportFailure);
}
private void ReportFailure(Exception exception) => Interlocked.CompareExchange(ref failure, exception, null);
private sealed class Capture : IAudioCapture
{
private readonly CapturePcmHandler handler;
private readonly AVAudioEngine engine = new();
private readonly AVAudioFormat outputFormat;
private readonly AVAudioConverter converter;
private readonly AVAudioPcmBuffer converted;
private readonly AVAudioConverterInputHandler inputProvider;
private readonly Action<Exception> reportFailure;
private AVAudioPcmBuffer? pendingInput;
private bool inputProvided;
private int disposed;
internal Capture(string? deviceId, CapturePcmHandler handler, Action<Exception> reportFailure)
{
this.handler = handler;
this.reportFailure = reportFailure;
AVAudioInputNode input = engine.InputNode;
if (!string.IsNullOrEmpty(deviceId))
{
AudioUnitStatus status = input.AudioUnit!.SetCurrentDevice(CoreAudioDevices.ParseId(deviceId), AudioUnitScopeType.Global, 0);
if (status != AudioUnitStatus.NoError) throw new InvalidOperationException($"Core Audio input selection failed ({status}).");
}
AVAudioFormat inputFormat = input.GetBusOutputFormat(0);
uint channels = Math.Clamp(inputFormat.ChannelCount, 1u, 2u);
outputFormat = new(AVAudioCommonFormat.PCMInt16, 48_000, channels, true);
converter = new(inputFormat, outputFormat);
uint outputCapacity = checked((uint)Math.Ceiling(4_096 * 48_000 / inputFormat.SampleRate) + 64);
converted = new(outputFormat, outputCapacity);
inputProvider = ProvideInput;
NSError? tapError = null;
if (OperatingSystem.IsMacOSVersionAtLeast(27))
input.InstallTapOnBus(0, 960, inputFormat, out tapError, Convert);
else
input.InstallTapOnBus(0, 960, inputFormat, Convert);
if (tapError is not null)
{
converted.Dispose();
converter.Dispose();
outputFormat.Dispose();
engine.Dispose();
throw new InvalidOperationException("Core Audio capture tap could not be installed: " + tapError.LocalizedDescription);
}
engine.Prepare();
if (!engine.StartAndReturnError(out var error))
{
input.RemoveTapOnBus(0);
converted.Dispose();
converter.Dispose();
outputFormat.Dispose();
engine.Dispose();
throw new InvalidOperationException("Core Audio capture could not start: " + error.LocalizedDescription);
}
}
private unsafe void Convert(AVAudioPcmBuffer input, AVAudioTime _time)
{
try
{
pendingInput = input;
inputProvided = false;
converted.FrameLength = 0;
AVAudioConverterOutputStatus result = converter.ConvertToBuffer(converted, out NSError? error, inputProvider);
if (result == AVAudioConverterOutputStatus.Error)
{
reportFailure(new InvalidOperationException("Core Audio input conversion failed: " + error?.LocalizedDescription));
return;
}
if (converted.FrameLength == 0) return;
nint samples = Marshal.ReadIntPtr(converted.Int16ChannelData);
if (samples == 0) return;
int channels = checked((int)outputFormat.ChannelCount);
int remainingFrames = checked((int)converted.FrameLength);
var pcm = new ReadOnlySpan<short>((void*)samples, checked(remainingFrames * channels));
while (remainingFrames > 0)
{
int frames = Math.Min(960, remainingFrames);
handler(pcm[..(frames * channels)], channels);
pcm = pcm[(frames * channels)..];
remainingFrames -= frames;
}
}
catch (Exception exception) { reportFailure(exception); }
finally { pendingInput = null; }
}
private AVAudioBuffer ProvideInput(uint _, out AVAudioConverterInputStatus status)
{
if (!inputProvided && pendingInput is { } input)
{
inputProvided = true;
status = AVAudioConverterInputStatus.HaveData;
return input;
}
status = AVAudioConverterInputStatus.NoDataNow;
return null!;
}
public void Dispose()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
engine.InputNode.RemoveTapOnBus(0);
engine.Stop();
converted.Dispose();
converter.Dispose();
outputFormat.Dispose();
engine.Dispose();
}
}
private sealed class Playback : IAudioPlayback
{
private readonly AdaptivePcmBuffer pcm = new(2);
private readonly short[] renderScratch = new short[8_192];
private readonly AVAudioEngine engine = new();
private readonly AVAudioFormat format;
private readonly AVAudioSourceNode source;
private readonly Action<Exception> reportFailure;
private int callbackFailed;
private int disposed;
internal Playback(string? deviceId, Action<Exception> reportFailure)
{
this.reportFailure = reportFailure;
if (!string.IsNullOrEmpty(deviceId))
{
AudioUnitStatus status = engine.OutputNode.AudioUnit!.SetCurrentDevice(CoreAudioDevices.ParseId(deviceId), AudioUnitScopeType.Global, 0);
if (status != AudioUnitStatus.NoError) throw new InvalidOperationException($"Core Audio output selection failed ({status}).");
}
// AVAudioEngine's native mixer path is planar Float32. Convert from the managed
// Int16 ring directly in this allocation-free callback, avoiding an extra graph
// converter and matching the established Swift/VPIO renderer.
format = new(AVAudioCommonFormat.PCMFloat32, 48_000, 2, false);
source = new(format, Render);
engine.AttachNode(source);
NSError? connectionError = null;
if (OperatingSystem.IsMacOSVersionAtLeast(27))
engine.Connect(source, engine.MainMixerNode, format, out connectionError);
else
engine.Connect(source, engine.MainMixerNode, format);
if (connectionError is not null)
{
engine.DetachNode(source);
source.Dispose();
format.Dispose();
engine.Dispose();
throw new InvalidOperationException("Core Audio playback could not be connected: " + connectionError.LocalizedDescription);
}
engine.Prepare();
if (!engine.StartAndReturnError(out var error))
{
engine.DetachNode(source);
source.Dispose();
format.Dispose();
engine.Dispose();
throw new InvalidOperationException("Core Audio playback could not start: " + error.LocalizedDescription);
}
}
public void Write(ReadOnlySpan<short> stereoPcm) => pcm.TryWrite(stereoPcm);
public int BufferMilliseconds { get => pcm.BufferMilliseconds; set => pcm.BufferMilliseconds = value; }
private unsafe int Render(IntPtr isSilence, IntPtr _, uint frameCount, IntPtr outputData)
{
try
{
int bufferCount = Marshal.ReadInt32(outputData);
int firstBuffer = IntPtr.Size == 8 ? 8 : 4;
const int bufferStride64 = 16;
int stride = IntPtr.Size == 8 ? bufferStride64 : 12;
int frames = checked((int)frameCount);
int requested = checked(frames * 2);
if (bufferCount != 2 || requested > renderScratch.Length) return Fail("Core Audio returned an invalid planar stereo playback buffer.");
Span<short> source = renderScratch.AsSpan(0, requested);
int read = pcm.Read(source);
source[read..].Clear();
for (int channel = 0; channel < 2; channel++)
{
int offset = firstBuffer + channel * stride;
int channels = Marshal.ReadInt32(outputData, offset);
int byteCount = Marshal.ReadInt32(outputData, offset + 4);
nint data = Marshal.ReadIntPtr(outputData, offset + 8);
if (channels != 1 || data == 0 || byteCount < frames * sizeof(float)) return Fail("Core Audio returned an invalid Float32 playback channel.");
var output = new Span<float>((void*)data, frames);
for (int frame = 0; frame < frames; frame++) output[frame] = source[frame * 2 + channel] * (1.0f / 32_768.0f);
}
if (isSilence != IntPtr.Zero) Marshal.WriteByte(isSilence, read == 0 ? (byte)1 : (byte)0);
return 0;
}
catch (Exception exception)
{
if (Interlocked.Exchange(ref callbackFailed, 1) == 0) reportFailure(exception);
return -1;
}
}
private int Fail(string message)
{
if (Interlocked.Exchange(ref callbackFailed, 1) == 0) reportFailure(new InvalidOperationException(message));
return -1;
}
public void Dispose()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
engine.Stop();
engine.DetachNode(source);
source.Dispose();
format.Dispose();
engine.Dispose();
}
}
}
@@ -0,0 +1,72 @@
using System.Security.Cryptography;
using System.Text;
using Foundation;
using Security;
namespace VoiceCat.Mac;
internal sealed class MacKeychainPasswordStore
{
private const string Service = "net.iamtalon.voicecat";
internal string? Load(Guid profileId)
{
using var query = Query(profileId);
using SecRecord? result = SecKeyChain.QueryAsRecord(query, out SecStatusCode status);
if (status != SecStatusCode.Success || result?.ValueData is not { } data) return null;
byte[] encoded = data.ToArray();
try { return Encoding.UTF8.GetString(encoded); }
finally { CryptographicOperations.ZeroMemory(encoded); }
}
internal string? LoadLegacy(string? keychainTag)
{
if (string.IsNullOrWhiteSpace(keychainTag)) return null;
using var query = new SecRecord(SecKind.GenericPassword) { Service = "cat.voice.VoiceCatMac", Account = keychainTag };
using SecRecord? result = SecKeyChain.QueryAsRecord(query, out SecStatusCode status);
if (status != SecStatusCode.Success || result?.ValueData is not { } data) return null;
byte[] encoded = data.ToArray();
try { return Encoding.UTF8.GetString(encoded); }
finally { CryptographicOperations.ZeroMemory(encoded); }
}
internal void Save(Guid profileId, string password)
{
ArgumentException.ThrowIfNullOrEmpty(password);
byte[] encoded = Encoding.UTF8.GetBytes(password);
try
{
using var data = NSData.FromArray(encoded);
using var query = Query(profileId);
using var attributes = new SecRecord
{
Label = "VoiceCat server password",
Accessible = SecAccessible.WhenUnlocked,
ValueData = data
};
SecStatusCode status = SecKeyChain.Update(query, attributes);
if (status == SecStatusCode.ItemNotFound)
{
using var record = Query(profileId);
record.Label = attributes.Label; record.Accessible = attributes.Accessible; record.ValueData = data;
status = SecKeyChain.Add(record);
}
if (status != SecStatusCode.Success) throw new InvalidOperationException($"Keychain save failed ({status}).");
}
finally { CryptographicOperations.ZeroMemory(encoded); }
}
internal void Remove(Guid profileId)
{
using var query = Query(profileId);
SecStatusCode status = SecKeyChain.Remove(query);
if (status is not (SecStatusCode.Success or SecStatusCode.ItemNotFound))
throw new InvalidOperationException($"Keychain removal failed ({status}).");
}
private static SecRecord Query(Guid profileId) => new(SecKind.GenericPassword)
{
Service = Service,
Account = profileId.ToString("D")
};
}
+71
View File
@@ -0,0 +1,71 @@
using Foundation;
using VoiceCat.Audio;
namespace VoiceCat.Mac;
internal sealed class MacSettings
{
private readonly NSUserDefaults values = NSUserDefaults.StandardUserDefaults;
internal AudioInputMode InputMode { get; set; } = AudioInputMode.VoiceActivation;
internal float VadThreshold { get; set; } = 0.05f;
internal float InputGain { get; set; } = 1f;
internal float OutputGain { get; set; } = 0.8f;
internal bool InputNoiseReduction { get; set; }
internal bool StereoMicrophone { get; set; }
internal ushort PushToTalkKeyCode { get; set; } = 0x60;
internal string? InputDeviceId { get; set; }
internal string? OutputDeviceId { get; set; }
internal bool AuxiliaryEnabled { get; set; }
internal string? AuxiliaryDeviceId { get; set; }
internal float AuxiliaryGain { get; set; } = 1f;
internal int AudioBufferMilliseconds { get; set; } = 40;
internal bool EventSounds { get; set; } = true;
internal bool SpokenEvents { get; set; }
internal float EventVolume { get; set; } = 1f;
internal bool SelfTalkSounds { get; set; }
internal bool PushToTalkSound { get; set; }
internal void Load()
{
NSString[] keys = [(NSString)"voice.inputMode", (NSString)"voice.vadThreshold", (NSString)"voice.inputGain", (NSString)"voice.outputGain",
(NSString)"voice.pttKeyCode", (NSString)"voice.auxGain", (NSString)"voice.audioBufferMs", (NSString)"feedback.sounds", (NSString)"feedback.volume"];
NSObject[] defaults = [NSNumber.FromInt32((int)AudioInputMode.VoiceActivation), NSNumber.FromFloat(0.05f),
NSNumber.FromFloat(1f), NSNumber.FromFloat(0.8f), NSNumber.FromInt32(0x60), NSNumber.FromFloat(1f), NSNumber.FromInt32(40),
NSNumber.FromBoolean(true), NSNumber.FromFloat(1f)];
values.RegisterDefaults(new NSDictionary<NSString, NSObject>(keys, defaults));
InputMode = Enum.IsDefined(typeof(AudioInputMode), (int)values.IntForKey("voice.inputMode"))
? (AudioInputMode)(int)values.IntForKey("voice.inputMode") : AudioInputMode.VoiceActivation;
VadThreshold = Math.Clamp(values.FloatForKey("voice.vadThreshold"), 0f, 1f);
InputGain = Math.Clamp(values.FloatForKey("voice.inputGain"), 0f, 4f);
OutputGain = Math.Clamp(values.FloatForKey("voice.outputGain"), 0f, 1f);
InputNoiseReduction = values.BoolForKey("voice.inputNoiseReduction");
StereoMicrophone = values.BoolForKey("voice.stereoMic");
PushToTalkKeyCode = checked((ushort)Math.Clamp(values.IntForKey("voice.pttKeyCode"), 0, ushort.MaxValue));
InputDeviceId = EmptyToNull(values.StringForKey("voice.inputDevice"));
OutputDeviceId = EmptyToNull(values.StringForKey("voice.outputDevice"));
AuxiliaryEnabled = values.BoolForKey("voice.auxEnabled");
AuxiliaryDeviceId = EmptyToNull(values.StringForKey("voice.auxDeviceUID"));
AuxiliaryGain = Math.Clamp(values.FloatForKey("voice.auxGain"), 0f, 4f);
int audioBuffer = checked((int)values.IntForKey("voice.audioBufferMs")); AudioBufferMilliseconds = audioBuffer is 20 or 40 or 60 ? audioBuffer : 40;
EventSounds = values.BoolForKey("feedback.sounds");
SpokenEvents = values.BoolForKey("feedback.speech");
EventVolume = Math.Clamp(values.FloatForKey("feedback.volume"), 0f, 1f);
SelfTalkSounds = values.BoolForKey("feedback.selfTalk");
PushToTalkSound = values.BoolForKey("feedback.ptt");
}
internal void Save()
{
values.SetInt((int)InputMode, "voice.inputMode"); values.SetFloat(VadThreshold, "voice.vadThreshold");
values.SetFloat(InputGain, "voice.inputGain"); values.SetFloat(OutputGain, "voice.outputGain");
values.SetBool(InputNoiseReduction, "voice.inputNoiseReduction"); values.SetBool(StereoMicrophone, "voice.stereoMic");
values.SetInt(PushToTalkKeyCode, "voice.pttKeyCode"); Set("voice.inputDevice", InputDeviceId); Set("voice.outputDevice", OutputDeviceId);
values.SetBool(AuxiliaryEnabled, "voice.auxEnabled"); Set("voice.auxDeviceUID", AuxiliaryDeviceId); values.SetFloat(AuxiliaryGain, "voice.auxGain"); values.SetInt(AudioBufferMilliseconds, "voice.audioBufferMs");
values.SetBool(EventSounds, "feedback.sounds"); values.SetBool(SpokenEvents, "feedback.speech"); values.SetFloat(EventVolume, "feedback.volume");
values.SetBool(SelfTalkSounds, "feedback.selfTalk"); values.SetBool(PushToTalkSound, "feedback.ptt"); values.Synchronize();
}
private void Set(string key, string? value) { if (value is null) values.RemoveObject(key); else values.SetString(value, key); }
private static string? EmptyToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
}
@@ -0,0 +1,486 @@
using AppKit;
using AVFoundation;
using CoreGraphics;
using Foundation;
using VoiceCat.Audio;
using VoiceCat.Core;
using Voicecat.V1;
namespace VoiceCat.Mac;
internal sealed class MainWindowController : NSWindowController
{
private readonly VoiceCatClient client;
private readonly MacAudioBackend audioBackend = new();
private readonly uint selfId;
private readonly NSPopUpButton channels = new(new CGRect(20, 515, 245, 28), false);
private readonly NSPopUpButton inputDevice = new(new CGRect(275, 515, 165, 28), false);
private readonly NSPopUpButton outputDevice = new(new CGRect(450, 515, 165, 28), false);
private readonly NSTextView users = new(new CGRect(0, 0, 190, 430)) { Editable = false, Selectable = true };
private readonly NSTextView chat = new(new CGRect(0, 0, 510, 390)) { Editable = false, Selectable = true };
private readonly NSPopUpButton messageTarget = new(new CGRect(230, 55, 160, 28), false);
private readonly NSTextField compose = new(new CGRect(400, 55, 230, 28)) { PlaceholderString = "Message" };
private readonly NSButton send = new(new CGRect(640, 53, 90, 32)) { Title = "Send" };
private readonly NSButton voice = new(new CGRect(630, 510, 100, 32)) { Title = "Join voice" };
private readonly NSButton settingsButton = new(new CGRect(740, 510, 90, 32)) { Title = "Settings" };
private readonly NSButton screenAudioButton = new(new CGRect(840, 510, 130, 32)) { Title = "Share audio" };
private readonly NSButton administrationButton = new(new CGRect(840, 20, 130, 28)) { Title = "Administration" };
private readonly NSButton privateMessageButton = new(new CGRect(700, 20, 130, 28)) { Title = "Private chat" };
private readonly NSButton muteButton = NSButton.CreateCheckbox("Mute", () => { });
private readonly NSButton deafenButton = NSButton.CreateCheckbox("Deafen", () => { });
private readonly NSTextField status = NSTextField.CreateLabel("Connected");
private readonly MacSettings settings = new();
private readonly EventFeedback feedback;
private readonly NSTimer timer;
private uint currentChannel = 1;
private uint microphoneStreamId;
private uint auxiliaryStreamId;
private uint screenAudioStreamId;
private bool joinedVoice;
private bool changingChannel;
private IAudioCapture? microphone;
private IAudioCapture? auxiliary;
private IAudioPlayback? playback;
private ScreenAudioCapture? screenAudio;
private ScreenAudioSelection screenAudioSelection = ScreenAudioSelection.Default;
private SettingsWindowController? settingsWindow;
private AdministrationWindowController? administrationWindow;
private readonly Dictionary<uint, PrivateMessageWindowController> privateWindows = [];
private readonly HashSet<uint> talkingUsers = [];
private bool lastTalking;
private string? activeInputDevice;
private string? activeOutputDevice;
private string? activeAuxiliaryDevice;
private bool activeStereo;
private NSObject? pushToTalkMonitor;
private bool pushToTalkEngaged;
private long lastRemoteAudioTick;
internal MainWindowController(VoiceCatClient client, uint selfId, string nickname) : base(new NSWindow(new CGRect(0, 0, 1000, 570),
NSWindowStyle.Titled | NSWindowStyle.Closable | NSWindowStyle.Resizable | NSWindowStyle.Miniaturizable, NSBackingStore.Buffered, false))
{
this.client = client; this.selfId = selfId; settings.Load(); feedback = new(settings);
Window!.Title = $"VoiceCat — {nickname}"; Window.Center(); Window.MinSize = new CGSize(680, 480);
var content = Window.ContentView!;
((INSAccessibility)channels).AccessibilityLabel = "Channel"; channels.Activated += ChangeChannel; content.AddSubview(channels);
PopulateAudioDevices(inputDevice, true); PopulateAudioDevices(outputDevice, false);
((INSAccessibility)inputDevice).AccessibilityLabel = "Microphone input device"; inputDevice.Activated += ChangeAudioDevice; content.AddSubview(inputDevice);
((INSAccessibility)outputDevice).AccessibilityLabel = "Audio output device"; outputDevice.Activated += ChangeAudioDevice; content.AddSubview(outputDevice);
((INSAccessibility)voice).AccessibilityLabel = "Join or leave voice"; voice.Activated += ToggleVoice; content.AddSubview(voice);
((INSAccessibility)settingsButton).AccessibilityLabel = "Open audio and notification settings"; settingsButton.Activated += OpenSettings; content.AddSubview(settingsButton);
((INSAccessibility)screenAudioButton).AccessibilityLabel = "Start or stop sharing screen audio"; screenAudioButton.Activated += ToggleScreenAudio; content.AddSubview(screenAudioButton);
((INSAccessibility)administrationButton).AccessibilityLabel = "Open server moderation and administration"; administrationButton.Activated += OpenAdministration; content.AddSubview(administrationButton);
((INSAccessibility)privateMessageButton).AccessibilityLabel = "Open a private conversation with the selected message recipient"; privateMessageButton.Activated += OpenPrivateMessage; content.AddSubview(privateMessageButton);
muteButton.Frame = new CGRect(275, 490, 75, 22); deafenButton.Frame = new CGRect(355, 490, 85, 22);
((INSAccessibility)muteButton).AccessibilityLabel = "Mute microphone"; ((INSAccessibility)deafenButton).AccessibilityLabel = "Deafen playback";
muteButton.Activated += SelfAudioChanged; deafenButton.Activated += SelfAudioChanged; content.AddSubview(muteButton); content.AddSubview(deafenButton);
var userScroll = new NSScrollView(new CGRect(20, 90, 190, 410)) { HasVerticalScroller = true, DocumentView = users }; userScroll.AccessibilityLabel = "Users in channel"; content.AddSubview(userScroll);
var chatScroll = new NSScrollView(new CGRect(230, 90, 500, 410)) { HasVerticalScroller = true, DocumentView = chat }; chatScroll.AccessibilityLabel = "Channel messages"; content.AddSubview(chatScroll);
((INSAccessibility)messageTarget).AccessibilityLabel = "Message recipient"; content.AddSubview(messageTarget);
((INSAccessibility)compose).AccessibilityLabel = "Message"; compose.Activated += Send; content.AddSubview(compose);
((INSAccessibility)send).AccessibilityLabel = "Send message"; send.Activated += Send; content.AddSubview(send);
status.Frame = new CGRect(20, 22, 660, 22); status.AccessibilityLabel = "Connection status"; content.AddSubview(status);
timer = NSTimer.CreateRepeatingScheduledTimer(TimeSpan.FromMilliseconds(50), _ => Pump());
ApplyEngineSettings(); feedback.Play(SoundEvent.Login); feedback.Speak("Connected");
pushToTalkMonitor = NSEvent.AddLocalMonitorForEventsMatchingMask(NSEventMask.KeyDown | NSEventMask.KeyUp, value => HandlePushToTalk(value)!);
RefreshState();
}
private NSEvent? HandlePushToTalk(NSEvent value)
{
if (settings.InputMode != AudioInputMode.PushToTalk || value.KeyCode != settings.PushToTalkKeyCode) return value;
bool engaged = value.Type == NSEventType.KeyDown;
client.Audio.PushToTalk = engaged;
if (engaged && !pushToTalkEngaged) feedback.Play(SoundEvent.PushToTalk);
pushToTalkEngaged = engaged; return null;
}
private void Pump()
{
while (client.TryReadEvent(out Envelope? envelope))
{
if (envelope!.TextMessage is { } text)
{
if (text.Scope == TextScope.TextPrivate)
{
uint other = text.SenderId == selfId ? text.TargetId : text.SenderId;
GetPrivateWindow(other).Append(text, Name(text.SenderId));
feedback.Play(text.SenderId == selfId ? SoundEvent.PrivateSent : SoundEvent.PrivateReceived);
if (text.SenderId != selfId) feedback.Speak($"Private message from {Name(text.SenderId)}: {text.Body}");
}
else { Append(FormatMessage(text)); feedback.Play(text.SenderId == selfId ? SoundEvent.ChannelSent : SoundEvent.ChannelReceived); }
}
if (envelope.StreamState is { } streamState)
{
if (streamState.Talking) talkingUsers.Add(streamState.UserId); else talkingUsers.Remove(streamState.UserId);
RefreshState();
}
if (envelope.UserEvent is { } userEvent)
{
string name = userEvent.User?.Nickname ?? Name(userEvent.LeftId);
if (userEvent.Kind == UserEvent.Types.Kind.Joined && userEvent.User?.ChannelId == currentChannel && userEvent.User.Id != selfId)
{ AppendActivity($"{name} joined the channel"); feedback.Play(SoundEvent.ChannelJoin); feedback.Speak($"{name} joined"); }
else if (userEvent.Kind == UserEvent.Types.Kind.Left)
{ talkingUsers.Remove(userEvent.LeftId); AppendActivity($"{name} left the server"); feedback.Play(SoundEvent.ChannelLeave); }
}
if (envelope.ServerState is not null || envelope.ChannelEvent is not null || envelope.UserEvent is not null) RefreshState();
if (envelope.Disconnect is { } disconnected)
{
StopAudioDevices(); joinedVoice = false; voice.Title = "Join voice";
status.StringValue = "Disconnected: " + disconnected.Reason; voice.Enabled = send.Enabled = messageTarget.Enabled = false;
feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost"); Announce(status.StringValue);
}
}
if (client.Audio.Failure is { } failure) { status.StringValue = "Audio stopped: " + failure.Message; return; }
if (audioBackend.Failure is { } deviceFailure) { status.StringValue = "Audio device stopped: " + deviceFailure.Message; return; }
if (joinedVoice)
{
(float level, bool talking) = client.Audio.GetLocalLevel(microphoneStreamId);
if (talking != lastTalking)
{
lastTalking = talking; client.PublishStreamState(microphoneStreamId, talking);
feedback.Play(talking ? SoundEvent.VoiceStart : SoundEvent.VoiceStop);
}
bool receiving = Environment.TickCount64 - Volatile.Read(ref lastRemoteAudioTick) < 1_000;
status.StringValue = $"Voice connected · Mic {(talking ? "sending" : "idle")} {level:P0} · Remote audio {(receiving ? "active" : "idle")}";
}
}
private void OpenSettings(object? sender, EventArgs args)
{
settingsWindow ??= new(this, settings, audioBackend);
settingsWindow.ShowWindow(this); settingsWindow.Window?.MakeKeyAndOrderFront(this);
}
private void OpenAdministration(object? sender, EventArgs args)
{
administrationWindow ??= new(client, selfId); administrationWindow.ShowWindow(this); administrationWindow.Window?.MakeKeyAndOrderFront(this);
}
private void OpenPrivateMessage(object? sender, EventArgs args)
{
uint target = uint.TryParse(messageTarget.SelectedItem?.RepresentedObject?.ToString(), out uint selected) ? selected : 0;
if (target == 0) { status.StringValue = "Choose a private message recipient first."; return; }
PrivateMessageWindowController window = GetPrivateWindow(target); window.ShowWindow(this); window.Window?.MakeKeyAndOrderFront(this);
}
private PrivateMessageWindowController GetPrivateWindow(uint userId)
{
if (privateWindows.TryGetValue(userId, out PrivateMessageWindowController? existing)) return existing;
var created = new PrivateMessageWindowController(client, userId, selfId, Name(userId)); privateWindows[userId] = created; return created;
}
private void SelfAudioChanged(object? sender, EventArgs args)
{
bool muted = muteButton.State == NSCellStateValue.On;
bool deafened = deafenButton.State == NSCellStateValue.On;
client.SetSelfAudioState(muted, deafened);
status.StringValue = deafened ? "Deafened" : muted ? "Microphone muted" : "Voice active";
}
private async void ToggleScreenAudio(object? sender, EventArgs args)
{
try
{
screenAudioButton.Enabled = false;
if (screenAudioStreamId != 0) { StopScreenAudio(); return; }
if (!joinedVoice) throw new InvalidOperationException("Join voice before sharing screen audio.");
ScreenAudioSelection? selection = await ScreenAudioPicker.ChooseAsync(screenAudioSelection);
if (selection is null) return;
screenAudioSelection = selection;
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamScreenAudio, "Desktop audio", 2);
screenAudioStreamId = stream.StreamId;
int channelCount = stream.Audio.Mode == ChannelMode.ModeStereo ? 2 : 1;
var capture = new ScreenAudioCapture(channelCount, screenAudioSelection,
(pcm, channels) => { uint id = screenAudioStreamId; if (id != 0) client.Audio.FeedPcm(id, pcm, channels); });
capture.Failed += exception => NSApplication.SharedApplication.InvokeOnMainThread(() => { status.StringValue = exception.Message; StopScreenAudio(); });
screenAudio = capture; await capture.StartAsync(); screenAudioButton.Title = "Stop sharing";
status.StringValue = "Sharing desktop audio";
}
catch (Exception exception) { StopScreenAudio(); status.StringValue = exception.Message; }
finally { screenAudioButton.Enabled = true; }
}
private void StopScreenAudio()
{
Interlocked.Exchange(ref screenAudio, null)?.Dispose();
uint id = screenAudioStreamId; screenAudioStreamId = 0;
TryStopStream(id);
screenAudioButton.Title = "Share audio";
}
private void ApplyEngineSettings()
{
client.Audio.InputMode = settings.InputMode; client.Audio.VadThreshold = settings.VadThreshold;
if (settings.InputMode != AudioInputMode.PushToTalk) { client.Audio.PushToTalk = false; pushToTalkEngaged = false; }
client.Audio.InputGain = settings.InputGain; client.Audio.OutputGain = settings.OutputGain;
client.Audio.InputNoiseReduction = settings.InputNoiseReduction;
client.Audio.DeviceBufferMilliseconds = settings.AudioBufferMilliseconds;
if (playback is not null) playback.BufferMilliseconds = settings.AudioBufferMilliseconds;
if (auxiliaryStreamId != 0) client.Audio.SetLocalGain(auxiliaryStreamId, settings.AuxiliaryGain);
}
internal async Task ApplySettingsAsync()
{
ApplyEngineSettings();
if (!joinedVoice) return;
bool restart = activeInputDevice != settings.InputDeviceId || activeOutputDevice != settings.OutputDeviceId || activeStereo != settings.StereoMicrophone;
if (restart) { await LeaveVoice(); await JoinVoice(); return; }
if (settings.AuxiliaryEnabled && auxiliaryStreamId == 0) await StartAuxiliaryAsync();
else if (!settings.AuxiliaryEnabled && auxiliaryStreamId != 0) StopAuxiliary();
else if (settings.AuxiliaryEnabled && activeAuxiliaryDevice != settings.AuxiliaryDeviceId) { StopAuxiliary(); await StartAuxiliaryAsync(); }
}
private void RefreshState()
{
uint previousTarget = uint.TryParse(messageTarget.SelectedItem?.RepresentedObject?.ToString(), out uint selectedTarget) ? selectedTarget : 0;
channels.RemoveAllItems();
IReadOnlyList<(Channel Channel, int Depth)> hierarchy = FlattenChannels(client.Channels);
foreach ((Channel channel, int depth) in hierarchy)
{
int members = client.Users.Count(user => user.ChannelId == channel.Id);
channels.AddItem(new string(' ', depth * 3) + channel.Name + $" ({members})" + (channel.PasswordProtected ? " [password]" : ""));
channels.LastItem!.RepresentedObject = new NSString(channel.Id.ToString());
channels.LastItem!.ToolTip = channel.Topic;
}
currentChannel = client.Users.FirstOrDefault(u => u.Id == selfId)?.ChannelId ?? currentChannel;
int selectedIndex = hierarchy.ToList().FindIndex(item => item.Channel.Id == currentChannel); if (selectedIndex >= 0) channels.SelectItem(selectedIndex);
users.Value = string.Join("\n", client.Users.Where(u => u.ChannelId == currentChannel).OrderBy(u => u.Nickname).Select(u =>
(talkingUsers.Contains(u.Id) ? "Speaking — " : "") + (u.Id == selfId ? "You — " : "") + u.Nickname +
(u.ServerDeafened ? " [server deafened]" : u.ServerMuted ? " [server muted]" : u.SelfDeafened ? " [deafened]" : u.SelfMicMuted ? " [muted]" : "")));
messageTarget.RemoveAllItems(); messageTarget.AddItem("Current channel"); messageTarget.LastItem!.RepresentedObject = new NSString("0");
foreach (var user in client.Users.Where(user => user.Id != selfId).OrderBy(user => user.Nickname))
{
messageTarget.AddItem("Private: " + user.Nickname);
messageTarget.LastItem!.RepresentedObject = new NSString(user.Id.ToString());
}
int targetIndex = client.Users.Where(user => user.Id != selfId).OrderBy(user => user.Nickname).ToList().FindIndex(user => user.Id == previousTarget) + 1;
messageTarget.SelectItem(Math.Max(0, targetIndex));
status.StringValue = $"Connected · {client.Users.Count} users";
}
private static IReadOnlyList<(Channel Channel, int Depth)> FlattenChannels(IReadOnlyList<Channel> values)
{
var result = new List<(Channel, int)>(); var visited = new HashSet<uint>();
void AddChildren(uint parent, int depth)
{
foreach (Channel channel in values.Where(channel => channel.ParentId == parent).OrderBy(channel => channel.Order).ThenBy(channel => channel.Name))
if (visited.Add(channel.Id)) { result.Add((channel, depth)); AddChildren(channel.Id, depth + 1); }
}
AddChildren(0, 0);
foreach (Channel channel in values.Where(channel => !visited.Contains(channel.Id)).OrderBy(channel => channel.Order).ThenBy(channel => channel.Name)) result.Add((channel, 0));
return result;
}
private async void ChangeChannel(object? sender, EventArgs args)
{
if (changingChannel || !uint.TryParse(channels.SelectedItem?.RepresentedObject?.ToString(), out uint id) || id == currentChannel) return;
var channel = client.Channels.FirstOrDefault(item => item.Id == id);
if (channel is null) { RefreshState(); return; }
string? password = channel.PasswordProtected ? PromptForChannelPassword(channel.Name) : "";
if (password is null) { RefreshState(); return; }
bool resumeVoice = joinedVoice;
try
{
changingChannel = true; channels.Enabled = false;
if (resumeVoice) await LeaveVoice();
var result = (await client.RequestAsync(new() { JoinChannel = new() { ChannelId = id, Password = password } })).JoinChannelResult;
if (result.Ok) currentChannel = id;
if (resumeVoice) await JoinVoice();
RefreshState();
status.StringValue = result.Ok ? $"Joined {channel.Name}" : result.Error;
}
catch (Exception exception)
{
string message = exception.Message;
if (resumeVoice && !joinedVoice)
{
try { await JoinVoice(); }
catch (Exception restoreException) { message += " Voice could not be restored: " + restoreException.Message; }
}
RefreshState(); status.StringValue = message;
}
finally { changingChannel = false; channels.Enabled = true; }
}
private string? PromptForChannelPassword(string channelName)
{
var field = new NSSecureTextField(new CGRect(0, 0, 320, 26)) { PlaceholderString = "Channel password" };
((INSAccessibility)field).AccessibilityLabel = $"Password for {channelName}";
var alert = new NSAlert
{
MessageText = $"Join {channelName}",
InformativeText = "Enter the channel password.",
AlertStyle = NSAlertStyle.Informational,
AccessoryView = field
};
alert.AddButton("Join"); alert.AddButton("Cancel");
if (alert.RunModal() != 1000) { field.StringValue = ""; return null; }
string value = field.StringValue; field.StringValue = ""; return value;
}
private void Send(object? sender, EventArgs args)
{
string body = compose.StringValue.Trim(); if (body.Length == 0) return;
uint target = uint.TryParse(messageTarget.SelectedItem?.RepresentedObject?.ToString(), out uint selectedTarget) ? selectedTarget : 0;
try { client.Send(new() { TextMessage = new() { Scope = target == 0 ? TextScope.TextChannel : TextScope.TextPrivate, TargetId = target == 0 ? currentChannel : target, Body = body, ClientMsgId = Guid.NewGuid().ToString("N") } }); compose.StringValue = ""; }
catch (Exception exception) { status.StringValue = exception.Message; }
}
private async void ToggleVoice(object? sender, EventArgs args)
{
try
{
voice.Enabled = false;
if (!joinedVoice) await JoinVoice();
else await LeaveVoice();
}
catch (Exception exception) { status.StringValue = exception.Message; }
finally { voice.Enabled = true; }
}
private void PopulateAudioDevices(NSPopUpButton menu, bool input)
{
IReadOnlyList<AudioDeviceInfo> devices = audioBackend.Enumerate(input);
foreach (AudioDeviceInfo device in devices)
{
menu.AddItem(device.Name + (device.IsDefault ? " (default)" : ""));
menu.LastItem!.RepresentedObject = new NSString(device.Id);
}
int selected = devices.ToList().FindIndex(device => device.IsDefault);
if (selected >= 0) menu.SelectItem(selected);
menu.Enabled = devices.Count > 0;
}
private async void ChangeAudioDevice(object? sender, EventArgs args)
{
if (!joinedVoice) return;
try { await LeaveVoice(); await JoinVoice(); }
catch (Exception exception) { status.StringValue = exception.Message; }
}
private async Task JoinVoice()
{
AVAuthorizationStatus permission = AVCaptureDevice.GetAuthorizationStatus(AVAuthorizationMediaType.Audio);
if (permission == AVAuthorizationStatus.NotDetermined)
{
bool granted = await AVCaptureDevice.RequestAccessForMediaTypeAsync(AVAuthorizationMediaType.Audio);
permission = granted ? AVAuthorizationStatus.Authorized : AVAuthorizationStatus.Denied;
}
if (permission != AVAuthorizationStatus.Authorized)
throw new UnauthorizedAccessException("Microphone access is disabled. Enable VoiceCat in System Settings → Privacy & Security → Microphone, then join voice again.");
var result = await client.SubscribeVoiceAsync();
if (!result.Ok) throw new InvalidOperationException(result.Error);
try
{
ApplyEngineSettings();
activeInputDevice = settings.InputDeviceId ?? SelectedDevice(inputDevice);
activeOutputDevice = settings.OutputDeviceId ?? SelectedDevice(outputDevice);
activeStereo = settings.StereoMicrophone;
playback = audioBackend.OpenPlayback(activeOutputDevice);
playback.BufferMilliseconds = settings.AudioBufferMilliseconds;
client.Audio.MixedPcm += PlayMixedPcm;
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamMic, "Microphone", settings.StereoMicrophone ? 2 : 1);
microphoneStreamId = stream.StreamId;
microphone = audioBackend.OpenCapture(activeInputDevice, false, FeedMicrophone);
if (settings.AuxiliaryEnabled) await StartAuxiliaryAsync();
joinedVoice = true; voice.Title = "Leave voice"; status.StringValue = "Voice connected";
feedback.Play(SoundEvent.VoiceOn);
}
catch
{
try { if (microphoneStreamId != 0) client.StopStream(microphoneStreamId); } catch { }
StopAudioDevices();
try { await client.SubscribeVoiceAsync(false); } catch { }
throw;
}
}
private async Task LeaveVoice()
{
uint streamId = microphoneStreamId;
joinedVoice = false; voice.Title = "Join voice";
StopAudioDevices();
if (streamId != 0) client.StopStream(streamId);
await client.SubscribeVoiceAsync(false);
status.StringValue = "Voice disconnected";
feedback.Play(SoundEvent.VoiceOff);
}
private async Task StartAuxiliaryAsync()
{
if (auxiliaryStreamId != 0) return;
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamAuxDevice, "Aux device", 2);
auxiliaryStreamId = stream.StreamId; client.Audio.SetLocalGain(stream.StreamId, settings.AuxiliaryGain);
try { activeAuxiliaryDevice = settings.AuxiliaryDeviceId; auxiliary = audioBackend.OpenCapture(activeAuxiliaryDevice, false, FeedAuxiliary); }
catch { client.StopStream(auxiliaryStreamId); auxiliaryStreamId = 0; throw; }
}
private void StopAuxiliary()
{
Interlocked.Exchange(ref auxiliary, null)?.Dispose();
activeAuxiliaryDevice = null;
uint id = auxiliaryStreamId; auxiliaryStreamId = 0;
TryStopStream(id);
}
private void FeedAuxiliary(ReadOnlySpan<short> pcm, int channels)
{
uint id = auxiliaryStreamId; if (id != 0) client.Audio.FeedPcm(id, pcm, channels);
}
private void FeedMicrophone(ReadOnlySpan<short> pcm, int channels)
{
uint streamId = microphoneStreamId;
if (streamId != 0) client.Audio.FeedPcm(streamId, pcm, channels);
}
private static string? SelectedDevice(NSPopUpButton menu) => menu.SelectedItem?.RepresentedObject?.ToString();
private void PlayMixedPcm(ReadOnlySpan<short> pcm)
{
bool signal = false;
foreach (short sample in pcm)
if (sample is > 64 or < -64) { signal = true; break; }
if (signal) Volatile.Write(ref lastRemoteAudioTick, Environment.TickCount64);
Volatile.Read(ref playback)?.Write(pcm);
}
private void StopAudioDevices()
{
StopScreenAudio();
StopAuxiliary();
microphoneStreamId = 0;
Volatile.Write(ref lastRemoteAudioTick, 0);
Interlocked.Exchange(ref microphone, null)?.Dispose();
client.Audio.MixedPcm -= PlayMixedPcm;
Interlocked.Exchange(ref playback, null)?.Dispose();
activeInputDevice = activeOutputDevice = null; lastTalking = false;
}
private void TryStopStream(uint id)
{
if (id == 0 || client.State != ClientConnectionState.Connected) return;
try { client.StopStream(id); }
catch (InvalidOperationException) { }
catch (IOException) { }
}
private string Name(uint id) => client.Users.FirstOrDefault(u => u.Id == id)?.Nickname ?? $"User {id}";
private string FormatMessage(TextMessage text)
{
string prefix = text.Scope switch
{
TextScope.TextPrivate => $"[private: {Name(text.SenderId == selfId ? text.TargetId : text.SenderId)}] ",
TextScope.TextServer => "[server] ",
_ => ""
};
return $"[{DateTime.Now:t}] {prefix}{Name(text.SenderId)}: {text.Body}";
}
private void Append(string line)
{
chat.Value = chat.Value.Length == 0 ? line : chat.Value + "\n" + line;
chat.ScrollRangeToVisible(new NSRange(chat.Value.Length, 0));
}
private void AppendActivity(string line) => Append($"[{DateTime.Now:t}] — {line}");
private void Announce(string value)
{
NSString[] keys = [NSAccessibilityNotificationUserInfoKeys.AnnouncementKey, NSAccessibilityNotificationUserInfoKeys.PriorityKey];
NSObject[] values = [(NSString)value, NSNumber.FromInt32(1)];
NSAccessibility.PostNotification(status, NSView.AnnouncementRequestedNotification, new NSDictionary<NSString, NSObject>(keys, values));
}
protected override void Dispose(bool disposing)
{
if (disposing) { timer.Invalidate(); if (pushToTalkMonitor is not null) NSEvent.RemoveMonitor(pushToTalkMonitor); client.Audio.PushToTalk = false; settingsWindow?.Close(); administrationWindow?.Close(); foreach (PrivateMessageWindowController window in privateWindows.Values) window.Close(); privateWindows.Clear(); StopAudioDevices(); feedback.Dispose(); client.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
base.Dispose(disposing);
}
}
@@ -0,0 +1,43 @@
using AppKit;
using CoreGraphics;
using VoiceCat.Core;
using Voicecat.V1;
namespace VoiceCat.Mac;
internal sealed class PrivateMessageWindowController : NSWindowController
{
private readonly VoiceCatClient client;
private readonly uint otherUserId;
private readonly uint selfId;
private readonly NSTextView transcript = new(new CGRect(0, 0, 430, 300)) { Editable = false, Selectable = true };
private readonly NSTextField compose = new(new CGRect(20, 20, 350, 28)) { PlaceholderString = "Private message" };
internal PrivateMessageWindowController(VoiceCatClient client, uint otherUserId, uint selfId, string nickname) : base(new NSWindow(
new CGRect(0, 0, 480, 400), NSWindowStyle.Titled | NSWindowStyle.Closable | NSWindowStyle.Resizable,
NSBackingStore.Buffered, false))
{
this.client = client; this.otherUserId = otherUserId; this.selfId = selfId;
Window!.Title = $"Private message — {nickname}"; Window.Center();
NSView view = Window.ContentView!;
var scroll = new NSScrollView(new CGRect(20, 65, 440, 315)) { DocumentView = transcript, HasVerticalScroller = true };
scroll.AccessibilityLabel = $"Private conversation with {nickname}"; view.AddSubview(scroll);
((INSAccessibility)compose).AccessibilityLabel = $"Message to {nickname}"; compose.Activated += Send; view.AddSubview(compose);
var button = new NSButton(new CGRect(380, 18, 80, 32)) { Title = "Send" }; button.Activated += Send; view.AddSubview(button);
}
private void Send(object? sender, EventArgs args)
{
string value = compose.StringValue.Trim(); if (value.Length == 0) return;
client.Send(new() { TextMessage = new() { Scope = TextScope.TextPrivate, TargetId = otherUserId, Body = value, ClientMsgId = Guid.NewGuid().ToString("N") } });
compose.StringValue = "";
}
internal void Append(TextMessage message, string sender)
{
string line = $"[{DateTime.Now:t}] {(message.SenderId == selfId ? "You" : sender)}: {message.Body}";
transcript.Value = transcript.Value.Length == 0 ? line : transcript.Value + "\n" + line;
transcript.ScrollRangeToVisible(new Foundation.NSRange(transcript.Value.Length, 0));
Window?.MakeKeyAndOrderFront(this);
}
}
+5
View File
@@ -0,0 +1,5 @@
using AppKit;
NSApplication.Init();
NSApplication.SharedApplication.Delegate = new VoiceCat.Mac.AppDelegate();
NSApplication.SharedApplication.Run();
@@ -0,0 +1,156 @@
using System.Runtime.InteropServices;
using CoreFoundation;
using CoreMedia;
using Foundation;
using ObjCRuntime;
using ScreenCaptureKit;
using VoiceCat.Audio;
namespace VoiceCat.Mac;
internal enum ScreenAudioScope { EntireDesktop, OnlyApplications, AllExceptApplications }
internal sealed record ScreenAudioSelection(ScreenAudioScope Scope, IReadOnlySet<string> BundleIdentifiers, bool ExcludeScreenReader)
{
internal static ScreenAudioSelection Default { get; } = new(ScreenAudioScope.EntireDesktop, new HashSet<string>(), false);
}
internal sealed class ScreenAudioCapture : NSObject, ISCStreamOutput
{
private static readonly HashSet<string> ScreenReaders = ["com.apple.VoiceOver", "com.apple.VoiceOver4", "com.apple.speech.speechsynthesisd"];
private readonly int channels;
private readonly CapturePcmHandler onPcm;
private readonly ScreenAudioSelection selection;
private readonly DispatchQueue queue = new("net.iamtalon.voicecat.screen-audio");
private readonly CaptureDelegate streamDelegate;
private SCStream? stream;
private int disposed;
internal ScreenAudioCapture(int channels, ScreenAudioSelection selection, CapturePcmHandler onPcm)
{
this.channels = Math.Clamp(channels, 1, 2); this.selection = selection; this.onPcm = onPcm;
streamDelegate = new(error => Failed?.Invoke(error));
}
internal event Action<Exception>? Failed;
internal static async Task<IReadOnlyList<ScreenApplication>> GetApplicationsAsync(CancellationToken cancellationToken = default)
{
SCShareableContent content = await GetContentAsync(cancellationToken).ConfigureAwait(false);
return content.Applications.Where(application => !string.IsNullOrWhiteSpace(application.BundleIdentifier) &&
!ScreenReaders.Contains(application.BundleIdentifier) && application.BundleIdentifier != NSBundle.MainBundle.BundleIdentifier)
.GroupBy(application => application.BundleIdentifier, StringComparer.Ordinal).Select(group => group.First())
.Select(application => new ScreenApplication(string.IsNullOrWhiteSpace(application.ApplicationName) ? application.BundleIdentifier : application.ApplicationName,
application.BundleIdentifier)).OrderBy(application => application.Name, StringComparer.CurrentCultureIgnoreCase).ToArray();
}
internal async Task StartAsync(CancellationToken cancellationToken = default)
{
SCShareableContent content = await GetContentAsync(cancellationToken).ConfigureAwait(false);
SCDisplay display = content.Displays.FirstOrDefault() ?? throw new InvalidOperationException("No display is available for screen audio capture.");
SCRunningApplication[] matching = content.Applications.Where(application => Included(application.BundleIdentifier)).ToArray();
SCContentFilterOption option = selection.Scope == ScreenAudioScope.OnlyApplications ? SCContentFilterOption.Include : SCContentFilterOption.Exclude;
var filter = new SCContentFilter(display, matching, [], option);
var configuration = new SCStreamConfiguration
{
CapturesAudio = true, ExcludesCurrentProcessAudio = true, SampleRate = 48_000,
ChannelCount = channels, Width = 2, Height = 2, QueueDepth = 6,
MinimumFrameInterval = new CMTime(1, 1)
};
var created = new SCStream(filter, configuration, streamDelegate);
if (!created.AddStreamOutput(this, SCStreamOutputType.Audio, queue, out NSError? outputError))
throw new InvalidOperationException(outputError?.LocalizedDescription ?? "Could not attach the screen-audio output.");
stream = created;
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
created.StartCapture(error => { if (error is null) started.TrySetResult(); else started.TrySetException(new InvalidOperationException(error.LocalizedDescription)); });
await started.Task.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken).ConfigureAwait(false);
}
private bool Included(string bundleIdentifier)
{
bool selected = selection.BundleIdentifiers.Contains(bundleIdentifier);
bool reader = selection.ExcludeScreenReader && ScreenReaders.Contains(bundleIdentifier);
return selection.Scope switch
{
ScreenAudioScope.OnlyApplications => selected,
ScreenAudioScope.AllExceptApplications => selected || reader,
_ => reader
};
}
[Export("stream:didOutputSampleBuffer:ofType:")]
public unsafe void DidOutputSampleBuffer(SCStream captureStream, CMSampleBuffer sampleBuffer, SCStreamOutputType type)
{
if (type != SCStreamOutputType.Audio || !sampleBuffer.DataIsReady || disposed != 0) return;
int frames = checked((int)sampleBuffer.NumSamples);
if (frames <= 0 || frames > 8192) return;
Span<byte> listStorage = stackalloc byte[40];
fixed (byte* list = listStorage)
{
int status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(sampleBuffer.Handle, out _, (IntPtr)list,
(nuint)listStorage.Length, IntPtr.Zero, IntPtr.Zero, 0, out IntPtr blockBuffer);
if (status != 0) return;
try
{
uint count = *(uint*)list;
if (count is 0 or > 2) return;
Span<short> converted = stackalloc short[frames * channels];
NativeAudioBuffer* first = (NativeAudioBuffer*)(list + 8);
bool planar = count > 1;
int sourceChannels = planar ? checked((int)count) : checked((int)Math.Max(1, first->Channels));
for (int frame = 0; frame < frames; frame++)
{
float left = planar ? ((float*)first[0].Data)[frame] : ((float*)first[0].Data)[frame * sourceChannels];
float right = sourceChannels > 1
? planar ? ((float*)first[1].Data)[frame] : ((float*)first[0].Data)[frame * sourceChannels + 1]
: left;
if (channels == 1) converted[frame] = ToInt16(sourceChannels > 1 ? (left + right) * 0.5f : left);
else { converted[frame * 2] = ToInt16(left); converted[frame * 2 + 1] = ToInt16(right); }
}
onPcm(converted, channels);
}
finally { if (blockBuffer != IntPtr.Zero) CFRelease(blockBuffer); }
}
}
private static short ToInt16(float value) => (short)Math.Clamp((int)MathF.Round(Math.Clamp(value, -1, 1) * 32767), short.MinValue, short.MaxValue);
private static Task<SCShareableContent> GetContentAsync(CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<SCShareableContent>(TaskCreationOptions.RunContinuationsAsynchronously);
SCShareableContent.GetShareableContent((content, error) =>
{
if (error is not null) completion.TrySetException(new UnauthorizedAccessException(error.LocalizedDescription));
else if (content is null) completion.TrySetException(new InvalidOperationException("ScreenCaptureKit returned no shareable content."));
else completion.TrySetResult(content);
});
cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
return completion.Task;
}
protected override void Dispose(bool disposing)
{
if (disposing && Interlocked.Exchange(ref disposed, 1) == 0)
{
SCStream? previous = Interlocked.Exchange(ref stream, null);
if (previous is null) { streamDelegate.Dispose(); queue.Dispose(); }
else previous.StopCapture(_ => { previous.Dispose(); streamDelegate.Dispose(); queue.Dispose(); });
}
base.Dispose(disposing);
}
[StructLayout(LayoutKind.Sequential)]
private struct NativeAudioBuffer { internal uint Channels, DataByteSize; internal IntPtr Data; }
private sealed class CaptureDelegate(Action<Exception> failed) : SCStreamDelegate
{
public override void DidStop(SCStream stream, NSError error) => failed(new IOException(error.LocalizedDescription));
}
[DllImport("/System/Library/Frameworks/CoreMedia.framework/CoreMedia")]
private static extern int CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(IntPtr sampleBuffer,
out nuint bufferListSizeNeeded, IntPtr bufferList, nuint bufferListSize, IntPtr structureAllocator,
IntPtr blockAllocator, uint flags, out IntPtr blockBuffer);
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
private static extern void CFRelease(IntPtr value);
}
@@ -0,0 +1,49 @@
using AppKit;
using CoreGraphics;
using Foundation;
using ScreenCaptureKit;
namespace VoiceCat.Mac;
internal sealed record ScreenApplication(string Name, string BundleIdentifier);
internal static class ScreenAudioPicker
{
internal static async Task<ScreenAudioSelection?> ChooseAsync(ScreenAudioSelection initial, CancellationToken cancellationToken = default)
{
IReadOnlyList<ScreenApplication> applications = await ScreenAudioCapture.GetApplicationsAsync(cancellationToken);
var mode = new NSPopUpButton(new CGRect(0, 270, 390, 28), false);
mode.AddItems(["Everything", "Only selected applications", "All except selected applications"]);
mode.SelectItem((int)initial.Scope);
((INSAccessibility)mode).AccessibilityLabel = "Screen audio sharing mode";
var source = new ApplicationSource(applications);
var table = new NSTableView(new CGRect(0, 0, 390, 225)) { DataSource = source, Delegate = source, AllowsMultipleSelection = true, HeaderView = null };
table.AddColumn(new NSTableColumn("application") { Title = "Application", Width = 370 });
((INSAccessibility)table).AccessibilityLabel = "Applications to include or exclude";
uint[] selected = applications.Select((application, index) => initial.BundleIdentifiers.Contains(application.BundleIdentifier) ? (uint?)index : null)
.Where(index => index.HasValue).Select(index => index!.Value).ToArray();
if (selected.Length > 0) table.SelectRows(NSIndexSet.FromArray(selected), false);
var scroll = new NSScrollView(new CGRect(0, 38, 390, 225)) { DocumentView = table, HasVerticalScroller = true, BorderType = NSBorderType.BezelBorder };
var exclude = NSButton.CreateCheckbox("Exclude VoiceOver and speech-synthesis audio", () => { });
exclude.Frame = new CGRect(0, 5, 390, 24); exclude.State = initial.ExcludeScreenReader ? NSCellStateValue.On : NSCellStateValue.Off;
((INSAccessibility)exclude).AccessibilityLabel = "Exclude screen reader audio";
var accessory = new NSView(new CGRect(0, 0, 390, 305)); accessory.AddSubview(mode); accessory.AddSubview(scroll); accessory.AddSubview(exclude);
var alert = new NSAlert { MessageText = "Choose screen audio", InformativeText = "Select what other people will hear.", AccessoryView = accessory };
alert.AddButton("Share"); alert.AddButton("Cancel");
if (alert.RunModal() != 1000) return null;
var bundleIdentifiers = table.SelectedRows.ToArray().Select(index => applications[checked((int)index)].BundleIdentifier).ToHashSet(StringComparer.Ordinal);
return new((ScreenAudioScope)(int)mode.IndexOfSelectedItem, bundleIdentifiers, exclude.State == NSCellStateValue.On);
}
private sealed class ApplicationSource(IReadOnlyList<ScreenApplication> applications) : NSTableViewDataSource, INSTableViewDelegate
{
public override nint GetRowCount(NSTableView tableView) => applications.Count;
[Export("tableView:viewForTableColumn:row:")]
public NSView GetViewForItem(NSTableView tableView, NSTableColumn? tableColumn, nint row)
{
var value = NSTextField.CreateLabel(applications[checked((int)row)].Name);
value.LineBreakMode = AppKit.NSLineBreakMode.TruncatingTail; return value;
}
}
}
@@ -0,0 +1,126 @@
using AppKit;
using CoreGraphics;
using VoiceCat.Audio;
namespace VoiceCat.Mac;
internal sealed class SettingsWindowController : NSWindowController
{
private readonly MainWindowController owner;
private readonly MacSettings settings;
private readonly MacAudioBackend audio;
private readonly NSPopUpButton mode = new(new CGRect(160, 455, 260, 28), false);
private readonly NSSlider vad = new(new CGRect(160, 415, 260, 24)) { MinValue = 0, MaxValue = 100 };
private readonly NSSlider inputGain = new(new CGRect(160, 375, 260, 24)) { MinValue = 0, MaxValue = 400 };
private readonly NSButton noiseReduction = NSButton.CreateCheckbox("Noise reduction (RNNoise)", () => { });
private readonly NSButton stereo = NSButton.CreateCheckbox("Stereo microphone", () => { });
private readonly NSPopUpButton input = new(new CGRect(160, 285, 260, 28), false);
private readonly NSPopUpButton output = new(new CGRect(160, 245, 260, 28), false);
private readonly NSSlider outputGain = new(new CGRect(160, 205, 260, 24)) { MinValue = 0, MaxValue = 100 };
private readonly NSButton auxiliary = NSButton.CreateCheckbox("Aux input stream", () => { });
private readonly NSPopUpButton auxiliaryDevice = new(new CGRect(160, 125, 260, 28), false);
private readonly NSSlider auxiliaryGain = new(new CGRect(160, 85, 260, 24)) { MinValue = 0, MaxValue = 400 };
private readonly NSButton sounds = NSButton.CreateCheckbox("Event sounds", () => { });
private readonly NSButton speech = NSButton.CreateCheckbox("Speak events", () => { });
private readonly NSButton selfTalk = NSButton.CreateCheckbox("Own voice-activity sounds", () => { });
private readonly NSButton pttSound = NSButton.CreateCheckbox("Push-to-talk cue", () => { });
private readonly NSButton pttKey = new(new CGRect(220, 12, 200, 32)) { Title = "Change PTT key…" };
private readonly NSSlider eventVolume = new(new CGRect(160, 505, 260, 24)) { MinValue = 0, MaxValue = 100 };
private readonly NSPopUpButton audioBuffer = new(new CGRect(160, 545, 260, 28), false);
internal SettingsWindowController(MainWindowController owner, MacSettings settings, MacAudioBackend audio) : base(
new NSWindow(new CGRect(0, 0, 460, 660), NSWindowStyle.Titled | NSWindowStyle.Closable,
NSBackingStore.Buffered, false))
{
this.owner = owner; this.settings = settings; this.audio = audio;
Window!.Title = "VoiceCat Settings"; Window.Center();
NSView view = Window.ContentView!;
mode.AddItems(["Voice activation", "Push to talk", "Always on"]);
audioBuffer.AddItems(["Low latency (20 ms)", "Balanced (40 ms)", "Stable (60 ms)"]);
Add(view, "Audio buffering", audioBuffer, 555);
Add(view, "Sound volume", eventVolume, 515);
Add(view, "Input mode", mode, 465); Add(view, "VAD sensitivity", vad, 425); Add(view, "Microphone volume", inputGain, 385);
noiseReduction.Frame = new CGRect(160, 340, 260, 24); stereo.Frame = new CGRect(160, 315, 260, 24);
view.AddSubview(noiseReduction); view.AddSubview(stereo);
Add(view, "Input device", input, 295); Add(view, "Output device", output, 255); Add(view, "Output volume", outputGain, 215);
auxiliary.Frame = new CGRect(160, 165, 260, 24); view.AddSubview(auxiliary);
Add(view, "Aux device", auxiliaryDevice, 135); Add(view, "Aux volume", auxiliaryGain, 95);
sounds.Frame = new CGRect(25, 45, 110, 24); speech.Frame = new CGRect(145, 45, 110, 24);
selfTalk.Frame = new CGRect(265, 45, 180, 24); pttSound.Frame = new CGRect(25, 18, 180, 24);
view.AddSubview(sounds); view.AddSubview(speech); view.AddSubview(selfTalk); view.AddSubview(pttSound); view.AddSubview(pttKey);
foreach (NSControl control in new NSControl[] { mode, vad, inputGain, noiseReduction, stereo, input, output,
outputGain, auxiliary, auxiliaryDevice, auxiliaryGain, sounds, speech, selfTalk, pttSound, eventVolume, audioBuffer }) control.Activated += Changed;
pttKey.Activated += CapturePushToTalkKey;
Populate(input, true, settings.InputDeviceId); Populate(output, false, settings.OutputDeviceId); Populate(auxiliaryDevice, true, settings.AuxiliaryDeviceId);
LoadValues(); SetAccessibility();
}
private static void Add(NSView view, string label, NSView control, double y)
{
NSTextField text = NSTextField.CreateLabel(label); text.Frame = new CGRect(25, y, 125, 22); view.AddSubview(text); view.AddSubview(control);
}
private void Populate(NSPopUpButton picker, bool capture, string? selected)
{
picker.RemoveAllItems(); IReadOnlyList<AudioDeviceInfo> devices = audio.Enumerate(capture);
foreach (AudioDeviceInfo device in devices) { picker.AddItem(device.Name + (device.IsDefault ? " (default)" : "")); picker.LastItem!.RepresentedObject = new Foundation.NSString(device.Id); }
int index = devices.ToList().FindIndex(device => device.Id == selected);
if (index < 0) index = devices.ToList().FindIndex(device => device.IsDefault);
if (index >= 0) picker.SelectItem(index); picker.Enabled = devices.Count > 0;
}
private void LoadValues()
{
mode.SelectItem((int)settings.InputMode); vad.DoubleValue = settings.VadThreshold * 100;
inputGain.DoubleValue = settings.InputGain * 100; outputGain.DoubleValue = settings.OutputGain * 100;
noiseReduction.State = State(settings.InputNoiseReduction); stereo.State = State(settings.StereoMicrophone);
auxiliary.State = State(settings.AuxiliaryEnabled); auxiliaryGain.DoubleValue = settings.AuxiliaryGain * 100;
sounds.State = State(settings.EventSounds); speech.State = State(settings.SpokenEvents);
eventVolume.DoubleValue = settings.EventVolume * 100;
selfTalk.State = State(settings.SelfTalkSounds); pttSound.State = State(settings.PushToTalkSound);
pttKey.Title = $"PTT key code: {settings.PushToTalkKeyCode}";
audioBuffer.SelectItem(settings.AudioBufferMilliseconds switch { 20 => 0, 60 => 2, _ => 1 });
UpdateEnabled();
}
private void CapturePushToTalkKey(object? sender, EventArgs args)
{
var alert = new NSAlert { MessageText = "Set push-to-talk key", InformativeText = "Press the key to use while VoiceCat is focused." };
alert.AddButton("Cancel"); NSObject? monitor = null;
monitor = NSEvent.AddLocalMonitorForEventsMatchingMask(NSEventMask.KeyDown, value =>
{
settings.PushToTalkKeyCode = value.KeyCode; settings.Save(); pttKey.Title = $"PTT key code: {value.KeyCode}";
NSApplication.SharedApplication.AbortModal(); return null!;
});
try { alert.RunModal(); }
finally { if (monitor is not null) NSEvent.RemoveMonitor(monitor); }
}
private async void Changed(object? sender, EventArgs args)
{
settings.InputMode = (AudioInputMode)mode.IndexOfSelectedItem; settings.VadThreshold = (float)vad.DoubleValue / 100;
settings.InputGain = (float)inputGain.DoubleValue / 100; settings.OutputGain = (float)outputGain.DoubleValue / 100;
settings.InputNoiseReduction = On(noiseReduction); settings.StereoMicrophone = On(stereo);
settings.InputDeviceId = Selected(input); settings.OutputDeviceId = Selected(output);
settings.AuxiliaryEnabled = On(auxiliary); settings.AuxiliaryDeviceId = Selected(auxiliaryDevice);
settings.AuxiliaryGain = (float)auxiliaryGain.DoubleValue / 100; settings.EventSounds = On(sounds);
settings.EventVolume = (float)eventVolume.DoubleValue / 100; settings.SpokenEvents = On(speech);
settings.SelfTalkSounds = On(selfTalk); settings.PushToTalkSound = On(pttSound);
settings.AudioBufferMilliseconds = audioBuffer.IndexOfSelectedItem switch { 0 => 20, 2 => 60, _ => 40 };
settings.Save(); UpdateEnabled(); await owner.ApplySettingsAsync();
}
private void UpdateEnabled() { vad.Enabled = settings.InputMode == AudioInputMode.VoiceActivation; auxiliaryDevice.Enabled = auxiliaryGain.Enabled = settings.AuxiliaryEnabled; }
private void SetAccessibility()
{
((INSAccessibility)mode).AccessibilityLabel = "Microphone input mode"; ((INSAccessibility)vad).AccessibilityLabel = "Voice activation sensitivity";
((INSAccessibility)inputGain).AccessibilityLabel = "Microphone volume"; ((INSAccessibility)input).AccessibilityLabel = "Microphone device";
((INSAccessibility)output).AccessibilityLabel = "Output device"; ((INSAccessibility)outputGain).AccessibilityLabel = "Output volume";
((INSAccessibility)auxiliaryDevice).AccessibilityLabel = "Auxiliary input device"; ((INSAccessibility)auxiliaryGain).AccessibilityLabel = "Auxiliary input volume";
((INSAccessibility)eventVolume).AccessibilityLabel = "Event sound volume";
((INSAccessibility)audioBuffer).AccessibilityLabel = "Audio buffering";
}
private static bool On(NSButton button) => button.State == NSCellStateValue.On;
private static NSCellStateValue State(bool value) => value ? NSCellStateValue.On : NSCellStateValue.Off;
private static string? Selected(NSPopUpButton picker) => picker.SelectedItem?.RepresentedObject?.ToString();
}
@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-macos27.0</TargetFramework>
<RuntimeIdentifier>osx-arm64</RuntimeIdentifier>
<SupportedOSPlatformVersion>14.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ApplicationTitle>VoiceCat</ApplicationTitle>
<ApplicationId>net.iamtalon.voicecat</ApplicationId>
<UseHardenedRuntime Condition="'$(Configuration)' == 'Release'">true</UseHardenedRuntime>
<UseHardenedRuntime Condition="'$(Configuration)' != 'Release'">false</UseHardenedRuntime>
<ApplicationManifest>Info.plist</ApplicationManifest>
<CodesignEntitlements>VoiceCat.Mac.entitlements</CodesignEntitlements>
<NoWarn>$(NoWarn);XCODE_27_0_PREVIEW</NoWarn>
<VoiceCatNativeMediaPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../artifacts/native/runtimes/osx-arm64/native/libvoicecat_media.dylib'))</VoiceCatNativeMediaPath>
<VoiceCatNativeLicenseDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../artifacts/native/licenses'))</VoiceCatNativeLicenseDirectory>
<_ComputePublishLocationDependsOn>VoiceCatPrepareNativeAssets;$(_ComputePublishLocationDependsOn)</_ComputePublishLocationDependsOn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
<BundleResource Include="../../../assets/sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
</ItemGroup>
<!-- Every managed media project stages the same dylib for ordinary .NET consumers.
The macOS bundler preserves those transitive items separately and otherwise runs
install_name_tool against the same temporary file concurrently. Collapse them to
the single native reference the application actually ships. -->
<Target Name="VoiceCatPrepareNativeAssets">
<ItemGroup>
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(Filename)%(Extension)' == 'libvoicecat_media.dylib'" />
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(Filename)%(Extension)' == 'NOTICE.txt' or '%(Filename)%(Extension)' == 'Opus.txt' or '%(Filename)%(Extension)' == 'RNNoise.txt'" />
<ResolvedFileToPublish Include="$(VoiceCatNativeMediaPath)" Condition="Exists('$(VoiceCatNativeMediaPath)')">
<RelativePath>libvoicecat_media.dylib</RelativePath>
<PublishFolderType>DynamicLibrary</PublishFolderType>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</ResolvedFileToPublish>
<ResolvedFileToPublish Include="$(VoiceCatNativeLicenseDirectory)/*.txt">
<RelativePath>licenses/%(Filename)%(Extension)</RelativePath>
<PublishFolderType>Assembly</PublishFolderType>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
</Project>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.cs.allow-jit</key><true/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.device.audio-input</key><true/>
<key>com.apple.security.files.user-selected.read-write</key><true/>
</dict></plist>
@@ -0,0 +1,58 @@
{
"version": 1,
"dependencies": {
"net10.0-macos27.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.audio": {
"type": "Project",
"dependencies": {
"VoiceCat.Codec": "[1.0.0, )",
"VoiceCat.Dsp": "[1.0.0, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.core": {
"type": "Project",
"dependencies": {
"VoiceCat.Audio": "[1.0.0, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0-macos27.0/osx-arm64": {}
}
}
@@ -0,0 +1,150 @@
using UIKit;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
namespace VoiceCat.iOS;
internal sealed class UsersController : UITableViewController
{
private readonly AppModel model; private IReadOnlyList<User> Visible => model.CurrentChannelId == 0 ? model.Users : model.Users.Where(user => user.ChannelId == model.CurrentChannelId).ToArray();
internal UsersController(AppModel model) { this.model = model; Title = "Users"; model.Changed += () => TableView.ReloadData(); }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "user"); }
public override nint RowsInSection(UITableView tableView, nint section) => Visible.Count;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
{
User user = Visible[indexPath.Row]; UITableViewCell cell = tableView.DequeueReusableCell("user", indexPath); var content = cell.DefaultContentConfiguration;
content.Text = user.Nickname + (user.Id == model.SelfUserId ? " (you)" : "");
content.SecondaryText = user.ServerDeafened ? "server deafened" : user.ServerMuted ? "server muted" : user.SelfDeafened ? "deafened" : user.SelfMicMuted ? "muted" : user.IsGuest ? "guest" : "account";
content.Image = UIImage.GetSystemImage(user.ServerMuted || user.SelfMicMuted ? "mic.slash.fill" : "mic.fill"); cell.ContentConfiguration = content; cell.AccessibilityLabel = $"{content.Text}, {content.SecondaryText}";
cell.Accessory = user.Id == model.SelfUserId ? UITableViewCellAccessory.None : UITableViewCellAccessory.DisclosureIndicator;
if (user.Id != model.SelfUserId) cell.AccessibilityCustomActions =
[
new("Open user details", (Func<UIAccessibilityCustomAction, bool>)(_ => { Open(user.Id); return true; })),
new("Private message", (Func<UIAccessibilityCustomAction, bool>)(_ => { NavigationController?.PushViewController(new PrivateConversationController(model, user.Id, user.Nickname), true); return true; }))
];
return cell;
}
public override void RowSelected(UITableView tableView, NSIndexPath indexPath)
{
User user = Visible[indexPath.Row]; tableView.DeselectRow(indexPath, true); if (user.Id == model.SelfUserId) return;
Open(user.Id);
}
private void Open(uint userId) => NavigationController?.PushViewController(new UserDetailController(model, userId), true);
}
internal sealed class UserDetailController : FormController
{
private readonly AppModel model; private readonly uint userId;
private User? User => model.Users.FirstOrDefault(value => value.Id == userId);
private IReadOnlyList<(string Title, Action Run, bool Destructive)> AdminActions
{
get
{
User? user = User; if (user is null) return [];
Permissions permissions = model.Client?.Permissions ?? new(); var result = new List<(string, Action, bool)>();
if (permissions.CanKick || permissions.IsAdmin) result.Add(("Kick", () => PromptReason(user, false), true));
if (permissions.CanBan || permissions.IsAdmin) result.Add(("Ban", () => NavigationController?.PushViewController(new BanUserController(model, user), true), true));
if (permissions.CanMoveUsers || permissions.IsAdmin) result.Add(("Move to channel", () => NavigationController?.PushViewController(new MoveUserController(model, user), true), false));
if (permissions.IsAdmin)
{
result.Add((user.ServerMuted ? "Server unmute" : "Server mute", () => Run(() => model.Client!.SetServerMuteAsync(user.Id, !user.ServerMuted, user.ServerDeafened)), false));
result.Add((user.ServerDeafened ? "Server undeafen" : "Server deafen", () => Run(() => model.Client!.SetServerMuteAsync(user.Id, user.ServerMuted, !user.ServerDeafened)), false));
result.Add(("Permissions", () => NavigationController?.PushViewController(new PermissionsController(model, user), true), false));
}
return result;
}
}
internal UserDetailController(AppModel model, uint userId) : base(model.Users.FirstOrDefault(value => value.Id == userId)?.Nickname ?? $"User {userId}")
{ this.model = model; this.userId = userId; model.Changed += Reload; }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "detail"); Reload(); }
public override nint NumberOfSections(UITableView tableView) => AdminActions.Count == 0 ? 2 : 3;
public override nint RowsInSection(UITableView tableView, nint section) => section switch
{ 0 => Math.Max(User?.Streams.Count ?? 0, 1), 1 => 1, _ => AdminActions.Count };
public override string? TitleForHeader(UITableView tableView, nint section) => section switch { 0 => "Audio streams", 1 => "Conversation", _ => "Administration" };
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
{
User? user = User;
if (indexPath.Section == 0)
{
if (user is null || user.Streams.Count == 0)
{
UITableViewCell empty = TextCell(tableView, indexPath, "detail", user is null ? "User is offline" : "No active audio streams");
empty.SelectionStyle = UITableViewCellSelectionStyle.None; empty.AccessibilityTraits |= UIAccessibilityTrait.NotEnabled; return empty;
}
StreamInfo stream = user.Streams[indexPath.Row]; string kind = stream.Kind == StreamKind.StreamMic ? "Microphone" : "Screen audio";
(float Gain, bool Muted, bool NoiseReduction)? state = model.Client?.Audio.GetRemotePlayback(userId, stream.StreamId);
string status = state is null ? kind : $"{kind} · {state.Value.Gain:P0}{(state.Value.Muted ? " · muted" : "")}{(state.Value.NoiseReduction && stream.Kind == StreamKind.StreamMic ? " · noise reduction" : "")}";
string displayName = string.IsNullOrWhiteSpace(stream.Label) ? kind : stream.Label; UITableViewCell cell = TextCell(tableView, indexPath, "detail", displayName, status);
cell.Accessory = UITableViewCellAccessory.DisclosureIndicator; cell.AccessibilityLabel = $"{displayName}, {status}"; return cell;
}
if (indexPath.Section == 1)
{
UITableViewCell cell = TextCell(tableView, indexPath, "detail", "Private message", user is null ? "User is offline" : null);
cell.Accessory = UITableViewCellAccessory.DisclosureIndicator; cell.UserInteractionEnabled = user is not null; if (user is null) cell.AccessibilityTraits |= UIAccessibilityTrait.NotEnabled; return cell;
}
(string title, _, bool destructive) = AdminActions[indexPath.Row]; UITableViewCell action = TextCell(tableView, indexPath, "detail", title);
if (destructive) { UIListContentConfiguration content = action.DefaultContentConfiguration; content.TextProperties.Color = UIColor.SystemRed; action.ContentConfiguration = content; } return action;
}
public override void RowSelected(UITableView tableView, NSIndexPath indexPath)
{
tableView.DeselectRow(indexPath, true); User? user = User; if (user is null) return;
if (indexPath.Section == 0 && user.Streams.Count > indexPath.Row)
NavigationController?.PushViewController(new PerUserTuningController(model, user.Id, user.Streams[indexPath.Row].StreamId), true);
else if (indexPath.Section == 1) NavigationController?.PushViewController(new PrivateConversationController(model, user.Id, user.Nickname), true);
else if (indexPath.Section == 2) AdminActions[indexPath.Row].Run();
}
private void Reload() { Title = User?.Nickname ?? $"User {userId}"; if (IsViewLoaded) TableView.ReloadData(); }
private void PromptReason(User user, bool ban) { UIAlertController prompt = UIAlertController.Create(ban ? "Ban user" : "Kick user", "Reason (optional)", UIAlertControllerStyle.Alert); prompt.AddTextField(field => field.AccessibilityLabel = "Reason"); prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create(ban ? "Ban" : "Kick", UIAlertActionStyle.Destructive, _ => Run(() => model.Client!.KickUserAsync(user.Id, prompt.TextFields?[0].Text ?? "")))); PresentViewController(prompt, true, null); }
private async void Run(Func<Task<GenericResult>> command) { try { GenericResult result = await model.RunAdminAsync(_ => command()); if (!result.Ok) throw new InvalidOperationException(result.Message); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
}
internal abstract class FormController : UITableViewController
{
protected FormController(string title) : base(UITableViewStyle.InsetGrouped) { Title = title; }
protected static UITableViewCell TextCell(UITableView table, NSIndexPath path, string id, string text, string? detail = null)
{ UITableViewCell cell = table.DequeueReusableCell(id, path); var content = cell.DefaultContentConfiguration; content.Text = text; content.SecondaryText = detail; cell.ContentConfiguration = content; return cell; }
protected static UISwitch Switch(bool value, string label, EventHandler handler) { var toggle = new UISwitch { On = value, AccessibilityLabel = label }; toggle.ValueChanged += handler; return toggle; }
}
internal sealed class PermissionsController : FormController
{
private readonly AppModel model; private readonly User user; private readonly string[] names = ["Create temporary channels", "Kick users", "Ban users", "Move users", "Manage accounts", "Administrator"];
private readonly bool[] values = new bool[6];
internal PermissionsController(AppModel model, User user) : base($"Permissions — {user.Nickname}") { this.model = model; this.user = user; }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "permission"); NavigationItem.RightBarButtonItem = new("Save", UIBarButtonItemStyle.Done, async (_, _) => await Save()); }
public override nint RowsInSection(UITableView tableView, nint section) => names.Length;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { UITableViewCell cell = TextCell(tableView, indexPath, "permission", names[indexPath.Row]); int row = indexPath.Row; cell.AccessoryView = Switch(values[row], names[row], (_, _) => values[row] = ((UISwitch)cell.AccessoryView!).On); return cell; }
private async Task Save() { try { var permissions = new Permissions { CanCreateTempChannel = values[0], CanKick = values[1], CanBan = values[2], CanMoveUsers = values[3], CanAdminAccounts = values[4], IsAdmin = values[5] }; GenericResult result = await model.RunAdminAsync(client => client.SetPermissionsAsync(user.Id, permissions)); if (!result.Ok) throw new InvalidOperationException(result.Message); NavigationController?.PopViewController(true); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
}
internal sealed class MoveUserController : FormController
{
private readonly AppModel model; private readonly User user; private uint selected;
internal MoveUserController(AppModel model, User user) : base($"Move {user.Nickname}") { this.model = model; this.user = user; selected = user.ChannelId; }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "channel"); NavigationItem.RightBarButtonItem = new("Move", UIBarButtonItemStyle.Done, async (_, _) => await Move()); }
public override nint RowsInSection(UITableView tableView, nint section) => model.Channels.Count;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { Channel channel = model.Channels[indexPath.Row]; UITableViewCell cell = TextCell(tableView, indexPath, "channel", channel.Name); cell.Accessory = channel.Id == selected ? UITableViewCellAccessory.Checkmark : UITableViewCellAccessory.None; cell.AccessibilityLabel = channel.Name + (channel.Id == selected ? ", selected" : ""); return cell; }
public override void RowSelected(UITableView tableView, NSIndexPath indexPath) { selected = model.Channels[indexPath.Row].Id; tableView.ReloadData(); }
private async Task Move() { try { GenericResult result = await model.RunAdminAsync(client => client.MoveUserAsync(user.Id, selected)); if (!result.Ok) throw new InvalidOperationException(result.Message); NavigationController?.PopViewController(true); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
}
internal sealed class BanUserController : UIViewController
{
private readonly AppModel model; private readonly User user; private readonly UITextField reason = UiHelpers.Field("Reason (optional)"); private readonly UISegmentedControl duration = new(["Permanent", "1 hour", "1 day", "1 week"]);
internal BanUserController(AppModel model, User user) { this.model = model; this.user = user; Title = $"Ban {user.Nickname}"; }
public override void ViewDidLoad() { base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemGroupedBackground; duration.SelectedSegment = 0; duration.AccessibilityLabel = "Ban duration"; UIStackView stack = new([reason, duration]) { Axis = UILayoutConstraintAxis.Vertical, Spacing = 16, TranslatesAutoresizingMaskIntoConstraints = false }; View.AddSubview(stack); NSLayoutConstraint.ActivateConstraints([stack.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor, 24), stack.LeadingAnchor.ConstraintEqualTo(View.LayoutMarginsGuide.LeadingAnchor), stack.TrailingAnchor.ConstraintEqualTo(View.LayoutMarginsGuide.TrailingAnchor)]); NavigationItem.RightBarButtonItem = new("Ban", UIBarButtonItemStyle.Done, async (_, _) => await Ban()); }
private async Task Ban() { try { ulong expires = duration.SelectedSegment switch { 1 => (ulong)DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds(), 2 => (ulong)DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds(), 3 => (ulong)DateTimeOffset.UtcNow.AddDays(7).ToUnixTimeMilliseconds(), _ => 0 }; GenericResult result = await model.RunAdminAsync(client => client.BanUserAsync(user.Id, reason.Text ?? "", expires)); if (!result.Ok) throw new InvalidOperationException(result.Message); NavigationController?.PopViewController(true); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
}
internal sealed class PerUserTuningController : FormController
{
private readonly AppModel model; private readonly uint userId, streamId; private readonly UISlider gain = new() { MinValue = 0, MaxValue = 4, Value = 1 };
private bool muted, noise;
private User? User => model.Users.FirstOrDefault(value => value.Id == userId);
private StreamInfo? Stream => User?.Streams.FirstOrDefault(value => value.StreamId == streamId);
internal PerUserTuningController(AppModel model, uint userId, uint streamId) : base("Audio tuning") { this.model = model; this.userId = userId; this.streamId = streamId; }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "tuning"); StreamInfo? stream = Stream; Title = stream?.Label ?? "Audio tuning"; gain.AccessibilityLabel = $"Volume gain for {User?.Nickname ?? $"User {userId}"}, {stream?.Label ?? "stream"}"; gain.ValueChanged += (_, _) => Apply(); if (model.Client?.Audio.GetRemotePlayback(userId, streamId) is { } state) { gain.Value = state.Gain; muted = state.Muted; noise = state.NoiseReduction; } }
public override nint RowsInSection(UITableView tableView, nint section) => Stream?.Kind == StreamKind.StreamMic ? 3 : 2;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { string title = indexPath.Row switch { 0 => "Gain", 1 => "Mute", _ => "Noise reduction" }; UITableViewCell cell = TextCell(tableView, indexPath, "tuning", title); if (indexPath.Row == 0) cell.AccessoryView = gain; else { int row = indexPath.Row; cell.AccessoryView = Switch(row == 1 ? muted : noise, title, (_, _) => { bool value = ((UISwitch)cell.AccessoryView!).On; if (row == 1) muted = value; else noise = value; Apply(); }); } return cell; }
private void Apply() { if (model.Client is not { } client || Stream is null) return; client.Audio.SetRemotePlayback(userId, streamId, gain.Value, muted, noise); }
}
+18
View File
@@ -0,0 +1,18 @@
using Foundation;
using UIKit;
namespace VoiceCat.iOS;
[Register("AppDelegate")]
internal sealed class AppDelegate : UIApplicationDelegate
{
public override bool FinishedLaunching(UIApplication application, NSDictionary? launchOptions)
{
AppModel.Shared.Load();
return true;
}
public override UISceneConfiguration GetConfiguration(UIApplication application, UISceneSession connectingSceneSession,
UISceneConnectionOptions options) => new("Default Configuration", connectingSceneSession.Role)
{ DelegateType = typeof(SceneDelegate) };
}
+375
View File
@@ -0,0 +1,375 @@
using UIKit;
using VoiceCat.Audio;
using VoiceCat.Core;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
namespace VoiceCat.iOS;
internal sealed record ChatEntry(DateTime Timestamp, uint SenderId, uint PeerUserId, string Sender, string Peer, string Text, bool Private);
internal sealed record ActivityEntry(DateTime Timestamp, string Text);
internal sealed class AppModel
{
internal static AppModel Shared { get; } = new();
private readonly IosStorage storage = new();
private readonly List<ServerProfile> profiles = [];
private readonly List<ChatEntry> messages = [];
private readonly List<ActivityEntry> activity = [];
private readonly IosSettings settings = new();
private readonly EventFeedback feedback;
private CancellationTokenSource? lifetime;
private VoiceCatClient? client;
private TaskCompletionSource<bool>? identityDecision;
private ServerProfile? connectedProfile;
private int reconnectAttempt;
private bool explicitDisconnect;
private uint microphoneStream;
private BroadcastAudioPump? broadcast;
private Timer? levelTimer;
private bool lastTalking;
private bool restoringVoice;
private uint restoreChannel;
private bool restoreMuted;
private bool restoreDeafened;
private bool backgrounded;
private int diagnosticPolls;
internal event Action? Changed;
internal event Action<ServerIdentityChallenge>? IdentityRequested;
internal IReadOnlyList<ServerProfile> Profiles => profiles;
internal IReadOnlyList<ChatEntry> Messages => messages;
internal IReadOnlyList<ActivityEntry> Activity => activity;
internal IosSettings Settings => settings;
internal VoiceCatClient? Client => client;
internal bool IsConnected => client?.State == ClientConnectionState.Connected;
internal bool SupportsAdaptivePacketLoss => client?.SupportsAdaptivePacketLoss == true;
internal bool IsConnecting { get; private set; }
internal bool VoiceJoined => microphoneStream != 0;
internal bool ScreenSharing => broadcast?.IsActive == true;
internal bool IsBackgrounded => backgrounded;
internal float MicrophoneLevel { get; private set; }
internal string Status { get; private set; } = "Not connected";
internal uint CurrentChannelId { get; private set; }
internal uint SelfUserId => client?.Authentication?.Self.Id ?? 0;
internal IReadOnlyList<Channel> Channels => client?.Channels ?? [];
internal IReadOnlyList<User> Users => client?.Users ?? [];
private AppModel() { feedback = new(settings); }
internal void Load() { profiles.Clear(); profiles.AddRange(storage.LoadProfiles()); settings.Load(); IosAudioRouter.Shared.Load(); Notify(); }
internal void Save() { storage.SaveProfiles(profiles); settings.Save(); }
internal void DidEnterBackground()
{
backgrounded = true; Save();
// Do not stop or rebuild AVAudioEngine here. With the `audio` background mode and an
// active PlayAndRecord session, capture, playback, media UDP, and screen-ring draining
// remain live while the scene is backgrounded or the device is locked.
}
internal void WillEnterForeground()
{
backgrounded = false;
IosAudioRouter.Shared.Recover("foreground");
}
internal void DidBecomeActive()
{
backgrounded = false;
IosAudioRouter.Shared.EnsureAudio("active scene");
Notify();
}
internal void UpsertProfile(ServerProfile profile, string? password)
{
int index = profiles.FindIndex(item => item.Id == profile.Id);
if (index < 0) profiles.Add(profile); else profiles[index] = profile;
if (!string.IsNullOrEmpty(password)) storage.SavePassword(profile.Id, password);
Save(); Notify();
}
internal void RemoveProfile(ServerProfile profile)
{
storage.RemovePassword(profile.Id); profiles.RemoveAll(item => item.Id == profile.Id); Save(); Notify();
}
internal async Task ConnectAsync(ServerProfile profile, string? suppliedPassword = null, bool restoring = false)
{
if (IsConnecting || IsConnected) return;
explicitDisconnect = false; IsConnecting = true; connectedProfile = profile;
Status = restoring ? "Reconnecting…" : "Connecting…"; Notify();
lifetime?.Cancel(); lifetime?.Dispose(); lifetime = new();
// The AVAudioSourceNode render callback drives Audio.RunCycle and the 20 ms capture
// handoff; a sleep-paced audio worker stalls when iOS coalesces backgrounded wakeups and
// the call glitches after several minutes in the background.
VoiceCatClient next = new("VoiceCat-iOS", "0.0.1", storage.TofuPath, deviceClockedAudio: true);
next.ConnectionStateChanged += state =>
{
if (state == ClientConnectionState.Disconnected && next.ConnectionFailure is { } failure)
Console.Error.WriteLine($"VoiceCat control connection failed: {failure}");
if (state == ClientConnectionState.Disconnected)
UIApplication.SharedApplication.BeginInvokeOnMainThread(async () =>
{
try { await HandleConnectionLostAsync(next); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Reconnect cleanup failed: {exception}"); }
});
};
client = next;
try
{
await next.ConnectAsync(profile.Host, profile.Port, ConfirmIdentityAsync, lifetime.Token);
AuthResult auth = profile.Authentication == ServerAuthentication.Guest
? await next.AuthenticateGuestAsync(profile.Nickname ?? "iOS User", lifetime.Token)
: await next.AuthenticateUserAsync(profile.Username!, suppliedPassword ?? storage.LoadPassword(profile) ?? "", lifetime.Token);
if (!auth.Ok) throw new InvalidOperationException(auth.Error);
CurrentChannelId = auth.Self.ChannelId; reconnectAttempt = 0; IsConnecting = false; Status = "Connected";
ApplyAudioSettings(next);
IosAudioEngine.Shared.StartListening(next);
broadcast = new(); broadcast.Changed += BroadcastChanged; broadcast.Start(next);
_ = PumpEventsAsync(next, lifetime.Token);
levelTimer?.Dispose(); levelTimer = new(_ =>
UIApplication.SharedApplication.BeginInvokeOnMainThread(PollAudio), null, 50, 50);
feedback.Play(SoundEvent.Login); feedback.Speak(restoring ? "Reconnected" : "Connected");
if (restoring && restoreChannel != 0) await RestoreSessionAsync(next);
Notify();
}
catch (Exception exception)
{
System.Diagnostics.Debug.WriteLine($"Connection failed: {exception}");
IsConnecting = false; Status = exception.Message;
if (ReferenceEquals(client, next)) { client = null; await StopSessionResourcesAsync(); }
await next.DisposeAsync();
Notify();
if (restoring && !explicitDisconnect) ScheduleReconnect();
else throw;
}
}
private ValueTask<bool> ConfirmIdentityAsync(ServerIdentityChallenge challenge, CancellationToken token)
{
identityDecision = new(TaskCreationOptions.RunContinuationsAsynchronously);
token.Register(() => identityDecision.TrySetCanceled(token));
UIApplication.SharedApplication.BeginInvokeOnMainThread(() => IdentityRequested?.Invoke(challenge));
return new(identityDecision.Task);
}
internal void ResolveIdentity(bool accepted) { identityDecision?.TrySetResult(accepted); identityDecision = null; }
private async Task PumpEventsAsync(VoiceCatClient owner, CancellationToken token)
{
try
{
await foreach (Envelope envelope in owner.ReadEventsAsync(token))
{
if (envelope.JoinChannelResult?.Ok == true) CurrentChannelId = envelope.JoinChannelResult.ChannelId;
if (envelope.JoinChannelResult is { Ok: false } joinFailure) AddActivity("Join failed: " + joinFailure.Error);
if (envelope.TextMessage is { } text)
{
User? sender = owner.Users.FirstOrDefault(user => user.Id == text.SenderId);
bool privateMessage = text.Scope == TextScope.TextPrivate;
uint peerUserId = privateMessage ? text.SenderId == SelfUserId ? text.TargetId : text.SenderId : 0;
User? peer = privateMessage ? owner.Users.FirstOrDefault(user => user.Id == peerUserId) : null;
messages.Add(new(DateTime.Now, text.SenderId, peerUserId, sender?.Nickname ?? $"User {text.SenderId}", peer?.Nickname ?? $"User {peerUserId}", text.Body, privateMessage));
if (messages.Count > 500) messages.RemoveAt(0);
bool self = text.SenderId == SelfUserId;
feedback.Play(text.Scope == TextScope.TextPrivate
? self ? SoundEvent.PrivateSent : SoundEvent.PrivateReceived
: self ? SoundEvent.ChannelSent : SoundEvent.ChannelReceived);
if (!self) feedback.Speak(text.Scope == TextScope.TextPrivate ? $"Private message from {sender?.Nickname}: {text.Body}" : $"{sender?.Nickname}: {text.Body}");
}
if (envelope.UserEvent is { } userEvent) HandleUserEvent(owner, userEvent);
if (envelope.StreamState is { } streamState) AddActivity($"{Name(owner, streamState.UserId)} {(streamState.Talking ? "started" : "stopped")} talking");
if (envelope.StreamAnnounceResult?.Ok == false) AddActivity("Stream failed: " + envelope.StreamAnnounceResult.Error);
if (envelope.GenericResult is { Ok: false } result) AddActivity("Operation failed: " + result.Message);
if (envelope.Disconnect is { } disconnected) AddActivity("Disconnected: " + disconnected.Reason);
UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify);
}
}
catch (OperationCanceledException) { }
finally
{
await HandleConnectionLostAsync(owner);
}
}
private async Task HandleConnectionLostAsync(VoiceCatClient owner)
{
if (!ReferenceEquals(client, owner) || IsConnecting || explicitDisconnect) return;
// A failed TLS reader changes State but leaves the event channel open until disposal.
// Handle that state change directly so a Wi-Fi transition cannot strand this session.
CaptureRestoreState(owner);
client = null; Status = "Connection lost";
try { await StopSessionResourcesAsync(); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio cleanup failed: {exception}"); }
try { await owner.DisposeAsync(); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Connection cleanup failed: {exception}"); }
feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost, reconnecting"); Notify();
ScheduleReconnect();
}
internal async Task JoinChannelAsync(uint channelId, string password = "")
{
VoiceCatClient active = client ?? throw new InvalidOperationException("Not connected.");
Envelope response = await active.RequestAsync(new() { JoinChannel = new() { ChannelId = channelId, Password = password } });
if (response.JoinChannelResult?.Ok != true) throw new InvalidOperationException(response.JoinChannelResult?.Error ?? "Join failed.");
CurrentChannelId = channelId; Notify();
}
internal void SendText(string body, uint targetUser = 0)
{
if (string.IsNullOrWhiteSpace(body)) return;
(client ?? throw new InvalidOperationException("Not connected.")).Send(new()
{
TextMessage = new() { Scope = targetUser == 0 ? TextScope.TextChannel : TextScope.TextPrivate,
TargetId = targetUser == 0 ? CurrentChannelId : targetUser, Body = body.Trim(), ClientMsgId = Guid.NewGuid().ToString("N") }
});
}
internal async Task ToggleVoiceAsync()
{
VoiceCatClient active = client ?? throw new InvalidOperationException("Not connected.");
if (microphoneStream != 0)
{
IosAudioEngine.Shared.StopMicrophone(); active.StopStream(microphoneStream); microphoneStream = 0;
await active.SubscribeVoiceAsync(false); MicrophoneLevel = 0; feedback.Play(SoundEvent.VoiceOff); Notify(); return;
}
if (AVFoundation.AVCaptureDevice.GetAuthorizationStatus(AVFoundation.AVAuthorizationMediaType.Audio) == AVFoundation.AVAuthorizationStatus.NotDetermined)
await AVFoundation.AVCaptureDevice.RequestAccessForMediaTypeAsync(AVFoundation.AVAuthorizationMediaType.Audio);
VoiceSubscriptionResult subscribed = await active.SubscribeVoiceAsync();
if (!subscribed.Ok) throw new InvalidOperationException(subscribed.Error);
int channels = IosAudioRouter.Shared.CaptureChannels;
StreamInfo stream = await active.StartStreamAsync(StreamKind.StreamMic, "Microphone", channels);
microphoneStream = stream.StreamId; IosAudioEngine.Shared.StartMicrophone(stream.StreamId, channels); feedback.Play(SoundEvent.VoiceOn); Notify();
}
internal void ToggleScreenAudio()
{
if (!OperatingSystem.IsIOSVersionAtLeast(27)) throw new PlatformNotSupportedException("Use the ReplayKit broadcast picker on this iOS version.");
if (ScreenSharing) { IosScreenCapture.Stop(); broadcast?.RequestStop(); }
else
{
if (!IsConnected || CurrentChannelId == 0) throw new InvalidOperationException("Connect and join a channel before sharing screen audio.");
IosScreenCapture.Present();
}
}
internal void SetSelfAudio(bool muted, bool deafened) { client?.SetSelfAudioState(muted, deafened); Notify(); }
internal void SetPushToTalk(bool active)
{
if (client is null) return; client.Audio.PushToTalk = active;
if (active) feedback.Play(SoundEvent.PushToTalk); Notify();
}
internal void ApplyVoiceSettings()
{
if (client is { } active) ApplyAudioSettings(active); settings.Save(); Notify();
}
internal async Task<GenericResult> RunAdminAsync(Func<VoiceCatClient, Task<GenericResult>> action)
{
GenericResult result = await action(client ?? throw new InvalidOperationException("Not connected."));
AddActivity(result.Ok ? (string.IsNullOrEmpty(result.Message) ? "Operation completed." : result.Message) : "Operation failed: " + result.Message);
Notify(); return result;
}
internal async Task DisconnectAsync()
{
explicitDisconnect = true; lifetime?.Cancel(); await StopSessionResourcesAsync();
VoiceCatClient? old = client; client = null; microphoneStream = 0; IsConnecting = false; Status = "Not connected"; Notify();
if (old is not null) await old.DisposeAsync(); feedback.Play(SoundEvent.Logout); feedback.Speak("Disconnected");
}
private void ScheduleReconnect()
{
ServerProfile? profile = connectedProfile; if (profile is null || explicitDisconnect) return;
int delay = Math.Min(1 << Math.Min(reconnectAttempt++, 5), 30);
CancellationToken token = lifetime?.Token ?? default;
_ = Task.Run(async () =>
{
try { await Task.Delay(TimeSpan.FromSeconds(delay), token); }
catch (OperationCanceledException) { return; }
UIApplication.SharedApplication.BeginInvokeOnMainThread(async () =>
{
if (token.IsCancellationRequested || explicitDisconnect) return;
try { await ConnectAsync(profile, restoring: true); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Reconnect failed: {exception}"); }
});
});
}
private void Notify() => Changed?.Invoke();
private void ApplyAudioSettings(VoiceCatClient owner)
{
owner.Audio.InputMode = settings.InputMode; owner.Audio.VadThreshold = settings.VadThreshold;
owner.Audio.InputGain = settings.InputGain; owner.Audio.OutputGain = settings.OutputGain;
owner.Audio.InputNoiseReduction = settings.InputNoiseReduction;
owner.Audio.DeviceBufferMilliseconds = settings.AudioBufferMilliseconds;
IosAudioEngine.Shared.BufferMilliseconds = settings.AudioBufferMilliseconds;
}
private void PollAudio()
{
VoiceCatClient? owner = client; uint stream = microphoneStream;
if (owner is null || owner.State != ClientConnectionState.Connected || stream == 0) return;
(float level, bool talking) = owner.Audio.GetLocalLevel(stream); MicrophoneLevel = level;
if (++diagnosticPolls >= 20)
{
diagnosticPolls = 0;
LocalAudioDiagnostics audio = owner.Audio.GetLocalDiagnostics(stream);
Console.Error.WriteLine($"VC_AUDIO {IosAudioEngine.Shared.CaptureDiagnostics()} cycles={audio.Cycles} starved={audio.StarvedCycles} " +
$"encoded={audio.EncodedPackets} packetDrops={audio.RejectedPackets} queued={audio.BufferedFrames}");
}
if (talking != lastTalking)
{
try { owner.PublishStreamState(stream, talking); }
catch (Exception exception) when (exception is IOException or InvalidOperationException or ObjectDisposedException) { return; }
lastTalking = talking; feedback.Play(talking ? SoundEvent.VoiceStart : SoundEvent.VoiceStop);
}
Notify();
}
private void HandleUserEvent(VoiceCatClient owner, UserEvent value)
{
string name = value.User?.Nickname ?? Name(owner, value.LeftId);
if (value.Kind == UserEvent.Types.Kind.Joined && value.User?.ChannelId == CurrentChannelId && value.User.Id != SelfUserId)
{ AddActivity($"{name} joined"); feedback.Play(SoundEvent.ChannelJoin); feedback.Speak($"{name} joined"); }
else if (value.Kind == UserEvent.Types.Kind.Left)
{ AddActivity($"{name} left"); feedback.Play(SoundEvent.ChannelLeave); feedback.Speak($"{name} left"); }
User? self = owner.Users.FirstOrDefault(user => user.Id == SelfUserId); if (self is not null) CurrentChannelId = self.ChannelId;
}
private void AddActivity(string text) { activity.Add(new(DateTime.Now, text)); if (activity.Count > 500) activity.RemoveAt(0); }
private static string Name(VoiceCatClient owner, uint id) => owner.Users.FirstOrDefault(user => user.Id == id)?.Nickname ?? $"User {id}";
private void BroadcastChanged()
{
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{
// Presenting either system picker can replace or interrupt the app's audio session.
// Rebuild after the producer becomes active so playback and an existing mic tap are
// attached to the session that will remain in use for the broadcast.
if (ScreenSharing) IosAudioRouter.Shared.Recover("screen sharing started");
Notify();
});
}
private void CaptureRestoreState(VoiceCatClient owner)
{
restoreChannel = CurrentChannelId; restoringVoice = microphoneStream != 0;
restoreMuted = owner.Audio.MicMuted; restoreDeafened = owner.Audio.Deafened;
}
private async Task RestoreSessionAsync(VoiceCatClient owner)
{
Envelope joined = await owner.RequestAsync(new() { JoinChannel = new() { ChannelId = restoreChannel } });
if (joined.JoinChannelResult?.Ok != true) { AddActivity("Could not restore the previous channel."); return; }
CurrentChannelId = restoreChannel;
if (restoringVoice) await ToggleVoiceAsync();
owner.SetSelfAudioState(restoreMuted, restoreDeafened); AddActivity($"Restored to channel {restoreChannel}{(restoringVoice ? " with voice" : "")}");
}
private async Task StopSessionResourcesAsync()
{
levelTimer?.Dispose(); levelTimer = null; IosAudioEngine.Shared.Stop(); microphoneStream = 0; MicrophoneLevel = 0; lastTalking = false;
if (broadcast is { } pump) { broadcast = null; pump.Changed -= BroadcastChanged; await pump.DisposeAsync(); }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,136 @@
using System.IO.MemoryMappedFiles;
using VoiceCat.Core;
using Voicecat.V1;
namespace VoiceCat.iOS;
internal sealed class BroadcastAudioPump : IAsyncDisposable
{
private const uint Magic = 0x56434252, Version = 1;
private const int Header = 64, Capacity = 96_000, Frame = 960;
private readonly CancellationTokenSource stop = new();
private readonly short[] scratch = new short[Frame * 2];
private Thread? worker;
private VoiceCatClient? client;
private MemoryMappedFile? map;
private MemoryMappedViewAccessor? view;
private string? path;
private uint streamId;
private bool active;
private int generation;
internal event Action? Changed;
internal bool IsActive => active;
internal void Start(VoiceCatClient owner)
{
client = owner;
worker = new Thread(Run) { IsBackground = true, Name = "VoiceCat iOS screen audio", Priority = ThreadPriority.AboveNormal };
worker.Start();
}
private void Run()
{
CancellationToken token = stop.Token;
try
{
while (!token.IsCancellationRequested)
{
try { DrainAsync(token).GetAwaiter().GetResult(); }
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or OperationCanceledException) { CloseMapping(); }
if (!token.IsCancellationRequested) Thread.Sleep(5);
}
}
finally { CloseMapping(); }
}
private async Task DrainAsync(CancellationToken token)
{
if (!OpenMapping()) return;
MemoryMappedViewAccessor pages = view!;
bool active = pages.ReadUInt32(16) != 0;
if (!active) { StopStream(); SetActive(false); return; }
VoiceCatClient owner = client ?? throw new IOException("Client disconnected.");
if (streamId == 0)
{
int startGeneration = Volatile.Read(ref generation);
StreamInfo stream = await owner.StartStreamAsync(StreamKind.StreamScreenAudio, "Screen audio", 2, token).ConfigureAwait(false);
if (startGeneration != Volatile.Read(ref generation) || pages.ReadUInt32(16) == 0 || !ReferenceEquals(client, owner))
{
try { owner.StopStream(stream.StreamId); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
return;
}
streamId = stream.StreamId; pages.Write(32, pages.ReadUInt64(24)); SetActive(true);
}
ulong write = pages.ReadUInt64(24), read = pages.ReadUInt64(32);
if (write - read > Capacity) read = write - Capacity;
while (write - read >= Frame * 2)
{
for (int sample = 0; sample < scratch.Length; sample++)
{
ulong index = (read + (ulong)sample) % Capacity;
scratch[sample] = pages.ReadInt16(Header + checked((long)index * sizeof(short)));
}
if (!owner.Audio.FeedPcm(streamId, scratch, 2)) break;
read += (ulong)scratch.Length;
pages.Write(32, read);
}
}
// The producer opens the ring with O_CREAT and never replaces it, so one mapping covers the
// pump's whole life. Creating and disposing a mapping (plus its container lookup and path
// strings) every 5 ms tick allocated steadily at 200 Hz and churned the GC under long calls;
// the mapping is now built on the ring's transitions and reused until the file disappears or
// a drain fails.
private bool OpenMapping()
{
path ??= ResolvePath();
if (path is null) return false;
bool present = File.Exists(path);
if (view is not null)
{
if (present) return true;
CloseMapping(); return false;
}
if (!present) return false;
MemoryMappedFile candidate = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite);
try
{
MemoryMappedViewAccessor pages = candidate.CreateViewAccessor(0, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite);
if (pages.ReadUInt32(0) != Magic || pages.ReadUInt32(4) != Version)
{
pages.Dispose(); throw new InvalidDataException("Unsupported broadcast ring.");
}
map = candidate; view = pages; return true;
}
catch { candidate.Dispose(); throw; }
}
private static string? ResolvePath()
{
NSUrl? root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup);
return root?.Path is null ? null : Path.Combine(root.Path, "voicecat", "broadcast_audio.ring");
}
private void CloseMapping()
{
view?.Dispose(); view = null;
map?.Dispose(); map = null;
}
private void StopStream()
{
uint id = streamId; streamId = 0; if (id == 0 || client?.State != ClientConnectionState.Connected) return;
try { client.StopStream(id); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
}
internal void RequestStop() { Interlocked.Increment(ref generation); SetActive(false); }
private void SetActive(bool value) { if (active == value) return; active = value; Changed?.Invoke(); }
public ValueTask DisposeAsync()
{
stop.Cancel(); Interlocked.Increment(ref generation); worker?.Join(); worker = null;
StopStream(); SetActive(false); stop.Dispose(); return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,57 @@
using UIKit;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
namespace VoiceCat.iOS;
internal sealed class ChannelEditorController : UIViewController
{
private readonly AppModel model; private readonly Channel? existing;
private readonly UITextField name = UiHelpers.Field("Channel name"), topic = UiHelpers.Field("Topic (optional)"), maximum = UiHelpers.Field("Maximum users, 0 is unlimited"), order = UiHelpers.Field("Sort order"), password = UiHelpers.Field("Password; blank preserves existing", true), bitrate = UiHelpers.Field("Bitrate in bits per second"), loss = UiHelpers.Field("Expected packet loss percent"), complexity = UiHelpers.Field("Complexity 0 through 10");
private readonly UISegmentedControl type = new(["Permanent", "Temporary"]), mode = new(["Mono", "Stereo"]), application = new(["VoIP", "Audio", "Low delay"]);
private readonly UIButton parent = UIButton.FromType(UIButtonType.System), sampleRate = UIButton.FromType(UIButtonType.System), frame = UIButton.FromType(UIButtonType.System);
private readonly UIButton lossSpeed = UIButton.FromType(UIButtonType.System);
private readonly UISwitch automaticLoss = new(), fec = new(), dtx = new(), dred = new(); private uint parentId;
internal ChannelEditorController(AppModel model, Channel? existing) { this.model = model; this.existing = existing; Title = existing is null ? "New Channel" : "Edit Channel"; }
public override void ViewDidLoad()
{
base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemGroupedBackground; UIScrollView scroll = new() { TranslatesAutoresizingMaskIntoConstraints = false }; UIStackView stack = new() { Axis = UILayoutConstraintAxis.Vertical, Spacing = 12, TranslatesAutoresizingMaskIntoConstraints = false };
View.AddSubview(scroll); scroll.AddSubview(stack); NSLayoutConstraint.ActivateConstraints([scroll.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor), scroll.BottomAnchor.ConstraintEqualTo(View.BottomAnchor), scroll.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor), scroll.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor), stack.TopAnchor.ConstraintEqualTo(scroll.ContentLayoutGuide.TopAnchor, 16), stack.BottomAnchor.ConstraintEqualTo(scroll.ContentLayoutGuide.BottomAnchor, -24), stack.LeadingAnchor.ConstraintEqualTo(scroll.FrameLayoutGuide.LeadingAnchor, 20), stack.TrailingAnchor.ConstraintEqualTo(scroll.FrameLayoutGuide.TrailingAnchor, -20)]);
foreach (UIView row in new UIView[] { name, topic, PickerRow("Parent channel", parent), PickerRow("Channel type", type), maximum, order, password, PickerRow("Channel mode", mode), PickerRow("Sample rate", sampleRate), PickerRow("Frame duration", frame), PickerRow("Opus application", application), bitrate, SwitchRow("Automatic packet loss", automaticLoss), PickerRow("Adaptation speed", lossSpeed), loss, complexity, SwitchRow("Forward error correction", fec), SwitchRow("Discontinuous transmission", dtx), SwitchRow("Deep redundancy", dred) }) stack.AddArrangedSubview(row);
maximum.KeyboardType = order.KeyboardType = bitrate.KeyboardType = loss.KeyboardType = complexity.KeyboardType = UIKeyboardType.NumberPad;
parent.Menu = UIMenu.Create(model.Channels.Where(channel => channel.Id != existing?.Id).OrderBy(channel => channel.Name).Select(channel => UIAction.Create(channel.Name, null, null, _ => { parentId = channel.Id; parent.SetTitle(channel.Name, UIControlState.Normal); })).Prepend(UIAction.Create("Root", null, null, _ => { parentId = 0; parent.SetTitle("Root", UIControlState.Normal); })).ToArray()); parent.ShowsMenuAsPrimaryAction = true;
sampleRate.Menu = Choice(sampleRate, ["48000"]); sampleRate.ShowsMenuAsPrimaryAction = true; frame.Menu = Choice(frame, ["5", "10", "20", "40", "60"]); frame.ShowsMenuAsPrimaryAction = true;
lossSpeed.Menu = Choice(lossSpeed, ["Fast", "Balanced", "Stable"]); lossSpeed.ShowsMenuAsPrimaryAction = true;
automaticLoss.Enabled = model.SupportsAdaptivePacketLoss;
automaticLoss.ValueChanged += (_, _) => UpdateLossControls();
Load(); NavigationItem.RightBarButtonItem = new("Save", UIBarButtonItemStyle.Done, async (_, _) => await Save());
}
private void Load()
{
Channel channel = existing ?? new Channel { Audio = new AudioConfig { SampleRate = 48000, BitrateBps = 64000, FrameMs = 20, Fec = true, Complexity = 10, ExpectedPacketLoss = 5 } };
name.Text = channel.Name; topic.Text = channel.Topic; maximum.Text = channel.MaxUsers.ToString(); order.Text = channel.Order.ToString(); parentId = channel.ParentId;
parent.SetTitle(model.Channels.FirstOrDefault(value => value.Id == parentId)?.Name ?? "Root", UIControlState.Normal); type.SelectedSegment = channel.Type == ChannelType.ChannelTemporary ? 1 : 0; mode.SelectedSegment = channel.Audio?.Mode == ChannelMode.ModeStereo ? 1 : 0;
sampleRate.SetTitle((channel.Audio?.SampleRate ?? 48000).ToString(), UIControlState.Normal); frame.SetTitle((channel.Audio?.FrameMs ?? 20).ToString(), UIControlState.Normal); application.SelectedSegment = (nint)(channel.Audio?.Application ?? OpusApplication.OpusVoip);
bitrate.Text = (channel.Audio?.BitrateBps ?? 64000).ToString(); loss.Text = (channel.Audio?.ExpectedPacketLoss ?? 5).ToString(); complexity.Text = (channel.Audio?.Complexity ?? 10).ToString(); fec.On = channel.Audio?.Fec ?? true; dtx.On = channel.Audio?.Dtx ?? false; dred.On = channel.Audio?.Dred ?? false;
automaticLoss.On = channel.Audio?.PacketLossMode != PacketLossMode.PacketLossManual;
lossSpeed.SetTitle(channel.Audio?.PacketLossMode switch { PacketLossMode.PacketLossAutoFast => "Fast", PacketLossMode.PacketLossAutoStable => "Stable", _ => "Balanced" }, UIControlState.Normal);
UpdateLossControls();
}
private async Task Save()
{
try
{
if (string.IsNullOrWhiteSpace(name.Text) || !uint.TryParse(maximum.Text, out uint max) || !int.TryParse(order.Text, out int sort) || !uint.TryParse(sampleRate.Title(UIControlState.Normal), out uint rate) || !uint.TryParse(frame.Title(UIControlState.Normal), out uint frameMs) || !uint.TryParse(bitrate.Text, out uint bits) || !uint.TryParse(loss.Text, out uint packetLoss) || !uint.TryParse(complexity.Text, out uint cpu) || packetLoss > 100 || cpu > 10) throw new ArgumentException("Enter valid channel and Opus settings.");
var channel = new Channel { Id = existing?.Id ?? 0, ParentId = parentId, Name = name.Text.Trim(), Topic = topic.Text?.Trim() ?? "", MaxUsers = max, Order = sort, Type = type.SelectedSegment == 1 ? ChannelType.ChannelTemporary : ChannelType.ChannelPermanent,
Audio = new AudioConfig { Codec = 0, Mode = mode.SelectedSegment == 1 ? ChannelMode.ModeStereo : ChannelMode.ModeMono, SampleRate = rate, FrameMs = frameMs, Application = (OpusApplication)(int)application.SelectedSegment, BitrateBps = bits, ExpectedPacketLoss = packetLoss, Complexity = cpu, Fec = fec.On, Dtx = dtx.On, Dred = dred.On,
PacketLossMode = automaticLoss.On ? lossSpeed.Title(UIControlState.Normal) switch { "Fast" => PacketLossMode.PacketLossAutoFast, "Stable" => PacketLossMode.PacketLossAutoStable, _ => PacketLossMode.PacketLossAutoBalanced } : PacketLossMode.PacketLossManual } };
GenericResult result = await model.RunAdminAsync(client => existing is null ? client.CreateChannelAsync(channel, password.Text ?? "") : client.EditChannelAsync(channel, password.Text ?? "")); if (!result.Ok) throw new InvalidOperationException(result.Message); NavigationController?.PopViewController(true);
}
catch (Exception exception) { UiHelpers.ShowError(this, exception); }
}
private void UpdateLossControls() { loss.Enabled = !automaticLoss.On; lossSpeed.Enabled = model.SupportsAdaptivePacketLoss && automaticLoss.On; }
private static UIView PickerRow(string label, UIView control) { var row = new UIStackView { Axis = UILayoutConstraintAxis.Horizontal, Distribution = UIStackViewDistribution.FillEqually, Spacing = 8 }; var text = new UILabel { Text = label }; control.AccessibilityLabel = label; row.AddArrangedSubview(text); row.AddArrangedSubview(control); return row; }
private static UIView SwitchRow(string label, UISwitch toggle) { var row = new UIStackView { Axis = UILayoutConstraintAxis.Horizontal, Distribution = UIStackViewDistribution.EqualSpacing }; row.AddArrangedSubview(new UILabel { Text = label }); toggle.AccessibilityLabel = label; row.AddArrangedSubview(toggle); return row; }
private static UIMenu Choice(UIButton button, string[] values) => UIMenu.Create(values.Select(value => UIAction.Create(value, null, null, _ => button.SetTitle(value, UIControlState.Normal))).ToArray());
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.security.application-groups</key><array><string>group.me.iamtalon.voicecat</string></array>
</dict></plist>
@@ -0,0 +1,49 @@
using AVFoundation;
using Foundation;
namespace VoiceCat.iOS;
internal enum SoundEvent { ChannelJoin, ChannelLeave, ChannelReceived, ChannelSent, PrivateReceived, PrivateSent, Login, Logout, ConnectionLost, VoiceOn, VoiceOff, VoiceStart, VoiceStop, PushToTalk }
internal sealed class EventFeedback : IDisposable
{
private readonly IosSettings settings;
private readonly AVSpeechSynthesizer speech = new();
private readonly Dictionary<SoundEvent, AVAudioPlayer> players = [];
internal EventFeedback(IosSettings settings) => this.settings = settings;
internal void Play(SoundEvent sound)
{
if (!settings.EventSounds || settings.EventVolume <= 0 ||
(sound is SoundEvent.VoiceStart or SoundEvent.VoiceStop && !settings.SelfTalkSounds) ||
(sound == SoundEvent.PushToTalk && !settings.PushToTalkSound)) return;
if (!players.TryGetValue(sound, out AVAudioPlayer? player))
{
string path = Path.Combine(NSBundle.MainBundle.ResourcePath ?? "", "Sounds", FileName(sound) + ".wav");
if (!File.Exists(path)) return;
player = AVAudioPlayer.FromUrl(NSUrl.FromFilename(path));
if (player is null) return;
player.PrepareToPlay(); players[sound] = player;
}
player.Volume = settings.EventVolume; player.CurrentTime = 0; player.Play();
}
internal void Speak(string text)
{
if (!settings.SpokenEvents || string.IsNullOrWhiteSpace(text)) return;
speech.SpeakUtterance(new AVSpeechUtterance(text.Trim()));
}
private static string FileName(SoundEvent value) => value switch
{
SoundEvent.ChannelJoin => "channel_join", SoundEvent.ChannelLeave => "channel_leave",
SoundEvent.ChannelReceived => "channel_recv", SoundEvent.ChannelSent => "channel_sent",
SoundEvent.PrivateReceived => "pm_recv", SoundEvent.PrivateSent => "pm_sent",
SoundEvent.Login => "login", SoundEvent.Logout => "logout", SoundEvent.ConnectionLost => "connection_lost",
SoundEvent.VoiceOn => "voice_on", SoundEvent.VoiceOff => "voice_off", SoundEvent.VoiceStart => "va_start",
SoundEvent.VoiceStop => "va_stop", _ => "ptt"
};
public void Dispose() { foreach (AVAudioPlayer player in players.Values) player.Dispose(); speech.Dispose(); }
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleDisplayName</key><string>VoiceCat</string>
<key>CFBundleIdentifier</key><string>me.iamtalon.voicecat</string>
<key>XSAppIconAssets</key><string>Assets.xcassets/AppIcon.appiconset</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>NSMicrophoneUsageDescription</key><string>VoiceCat needs microphone access to transmit your voice in channels.</string>
<key>UIBackgroundModes</key><array><string>audio</string><string>screen-capture</string></array>
<key>UIRequiresFullScreen</key><false/>
<key>UIDeviceFamily</key><array><integer>1</integer><integer>2</integer></array>
<key>UILaunchScreen</key><dict/>
<key>UISupportedInterfaceOrientations</key><array><string>UIInterfaceOrientationPortrait</string><string>UIInterfaceOrientationLandscapeLeft</string><string>UIInterfaceOrientationLandscapeRight</string></array>
<key>UISupportedInterfaceOrientations~ipad</key><array><string>UIInterfaceOrientationPortrait</string><string>UIInterfaceOrientationPortraitUpsideDown</string><string>UIInterfaceOrientationLandscapeLeft</string><string>UIInterfaceOrientationLandscapeRight</string></array>
<key>UIApplicationSceneManifest</key><dict>
<key>UIApplicationSupportsMultipleScenes</key><false/>
<key>UISceneConfigurations</key><dict><key>UIWindowSceneSessionRoleApplication</key><array><dict>
<key>UISceneConfigurationName</key><string>Default Configuration</string>
<key>UISceneDelegateClassName</key><string>VoiceCat.iOS.SceneDelegate</string>
</dict></array></dict>
</dict>
</dict></plist>
@@ -0,0 +1,254 @@
using System.Runtime.InteropServices;
using AVFoundation;
using UIKit;
using VoiceCat.Audio;
using VoiceCat.Core;
namespace VoiceCat.iOS;
internal sealed class IosAudioEngine
{
// InstallTapOnBus bufferSize is only a request. Physical iOS hardware can deliver 4,800
// frames per callback (observed with the voice-processing graph), so conversion storage
// must cover substantially more than the requested 960 frames without allocating in Capture.
private const int MaximumCaptureCallbackFrames = 16_384;
internal static IosAudioEngine Shared { get; } = new();
private readonly AdaptivePcmBuffer playbackRing = new(2, capacityFrames: 65_536);
private readonly short[] renderScratch = new short[16_384];
private AVAudioEngine? engine;
private Foundation.NSObject? engineConfigurationObserver;
private AVAudioSourceNode? source;
private AVAudioFormat? outputFormat;
private VoiceCatClient? client;
private sealed class MicrophoneRoute(uint streamId, int channels)
{
internal readonly uint StreamId = streamId;
internal readonly int Channels = channels;
// Capture hardware and the managed 20 ms sender have independent clocks. Correct
// their small rate difference before the queue eventually reaches its hard edge.
// RemoteIO can deliver capture in 100 ms bursts, so retain one burst of headroom.
internal readonly AdaptivePcmBuffer Ring = new(channels, 120, 65_536);
}
private MicrophoneRoute? microphone;
private readonly short[] microphoneFrame = new short[960 * 2];
private int microphoneCredit;
private AVAudioFormat? microphoneFormat;
private AVAudioConverter? microphoneConverter;
private AVAudioPcmBuffer? convertedMicrophone;
private AVAudioPcmBuffer? pendingInput;
private AVAudioConverterInputHandler? inputProvider;
private bool inputProvided;
private bool tapInstalled;
private long captureCallbacks, capturedFrames, convertedFrames, rejectedFeeds, converterFailures, stereoFrames, stereoDifferentFrames;
internal bool IsConnected { get; private set; }
internal int BufferMilliseconds { get => playbackRing.BufferMilliseconds; set => playbackRing.BufferMilliseconds = value; }
internal void StartListening(VoiceCatClient owner)
{
Stop(); client = owner; IsConnected = true; owner.Audio.MixedPcm += ReceiveMixedPcm; Rebuild();
}
internal void StartMicrophone(uint streamId, int channels)
{
Volatile.Write(ref microphone, CreateMicrophoneRoute(streamId, channels)); Rebuild();
}
internal void StopMicrophone() { Volatile.Write(ref microphone, null); Rebuild(); }
internal void Reconfigure()
{
if (!IsConnected) return;
MicrophoneRoute? current = Volatile.Read(ref microphone);
int channels = IosAudioRouter.Shared.CaptureChannels;
if (current is not null)
{
// Stop the old tap before publishing a route with a different sample width. Otherwise
// an in-flight callback could interpret its old converter buffer using the new width.
DestroyGraph();
if (current.Channels != channels) client!.Audio.SetCaptureChannels(current.StreamId, channels);
Volatile.Write(ref microphone, CreateMicrophoneRoute(current.StreamId, channels));
}
Rebuild();
}
private static MicrophoneRoute CreateMicrophoneRoute(uint streamId, int channels)
{
return new MicrophoneRoute(streamId, Math.Clamp(channels, 1, 2));
}
internal bool EnsureRunning()
{
if (!IsConnected || engine?.Running == true) return true;
Rebuild(); return engine?.Running == true;
}
private void Rebuild()
{
DestroyGraph(); MicrophoneRoute? route = Volatile.Read(ref microphone); IosAudioRouter.Shared.Apply(route is not null);
var next = new AVAudioEngine();
AVAudioInputNode? input = null;
if (route is not null)
{
// Enabling voice processing rebuilds both sides of AVAudioEngine. Do it before any
// formats are queried or nodes are connected so the graph is built from the final IO.
input = next.InputNode;
if (!input.SetVoiceProcessingEnabled(IosAudioRouter.Shared.UsesVoiceProcessing, out NSError? processingError))
throw new InvalidOperationException(processingError.LocalizedDescription);
if (IosAudioRouter.Shared.UsesVoiceProcessing) input.VoiceProcessingAgcEnabled = IosAudioRouter.Shared.AutomaticGainControl;
}
outputFormat = new(AVAudioCommonFormat.PCMFloat32, 48_000, 2, false);
source = new(outputFormat, Render);
next.AttachNode(source);
NSError? connectionError = null;
if (OperatingSystem.IsIOSVersionAtLeast(27)) next.Connect(source, next.MainMixerNode, outputFormat, out connectionError);
else next.Connect(source, next.MainMixerNode, outputFormat);
if (connectionError is not null) throw new InvalidOperationException(connectionError.LocalizedDescription);
if (route is not null)
{
AVAudioFormat inputFormat = input!.GetBusOutputFormat(0);
Console.Error.WriteLine($"VC_GRAPH vpio={IosAudioRouter.Shared.UsesVoiceProcessing} requestedCh={route.Channels} " +
$"inputCh={inputFormat.ChannelCount} inputRate={inputFormat.SampleRate}");
microphoneFormat = new(AVAudioCommonFormat.PCMInt16, 48_000, (uint)route.Channels, true);
microphoneConverter = new(inputFormat, microphoneFormat);
uint capacity = checked((uint)Math.Ceiling(MaximumCaptureCallbackFrames * 48_000 / inputFormat.SampleRate) + 64);
convertedMicrophone = new(microphoneFormat, capacity);
inputProvider = ProvideInput;
NSError? tapError = null;
if (OperatingSystem.IsIOSVersionAtLeast(27)) input.InstallTapOnBus(0, 960, inputFormat, out tapError, Capture);
else input.InstallTapOnBus(0, 960, inputFormat, Capture);
if (tapError is not null) throw new InvalidOperationException(tapError.LocalizedDescription);
tapInstalled = true;
}
next.Prepare();
if (!next.StartAndReturnError(out NSError? error)) { next.Dispose(); throw new InvalidOperationException(error.LocalizedDescription); }
engine = next;
engineConfigurationObserver = Foundation.NSNotificationCenter.DefaultCenter.AddObserver(
AVAudioEngine.ConfigurationChangeNotification, notification =>
{
if (!ReferenceEquals(notification.Object, next)) return;
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{
if (IsConnected && ReferenceEquals(engine, next) && !next.Running) Rebuild();
});
}, next);
}
private unsafe void Capture(AVAudioPcmBuffer buffer, AVAudioTime time)
{
Interlocked.Increment(ref captureCallbacks);
Interlocked.Add(ref capturedFrames, buffer.FrameLength);
VoiceCatClient? owner = client; MicrophoneRoute? route = Volatile.Read(ref microphone);
if (owner is null || route is null || buffer.FrameLength == 0) return;
AVAudioConverter? converter = microphoneConverter;
AVAudioPcmBuffer? converted = convertedMicrophone;
AVAudioConverterInputHandler? provider = inputProvider;
if (converter is null || converted is null || provider is null) return;
pendingInput = buffer; inputProvided = false; converted.FrameLength = 0;
converter.ConvertToBuffer(converted, out NSError? conversionError, provider);
if (conversionError is not null) Interlocked.Increment(ref converterFailures);
if (converted.FrameLength == 0) return;
Interlocked.Add(ref convertedFrames, converted.FrameLength);
nint samples = Marshal.ReadIntPtr(converted.Int16ChannelData);
if (samples != 0 && ReferenceEquals(route, Volatile.Read(ref microphone)))
{
var pcm = new ReadOnlySpan<short>((void*)samples, checked((int)converted.FrameLength * route.Channels));
if (route.Channels == 2)
{
Interlocked.Add(ref stereoFrames, converted.FrameLength);
long different = 0;
for (int frame = 0; frame < converted.FrameLength; frame++)
if (pcm[frame * 2] != pcm[frame * 2 + 1]) different++;
Interlocked.Add(ref stereoDifferentFrames, different);
}
if (!route.Ring.TryWrite(pcm)) Interlocked.Increment(ref rejectedFeeds);
}
pendingInput = null;
}
internal string CaptureDiagnostics()
{
return $"callbacks={Interlocked.Exchange(ref captureCallbacks, 0)} input={Interlocked.Exchange(ref capturedFrames, 0)} " +
$"converted={Interlocked.Exchange(ref convertedFrames, 0)} feedDrops={Interlocked.Exchange(ref rejectedFeeds, 0)} " +
$"converterErrors={Interlocked.Exchange(ref converterFailures, 0)} stereo={Interlocked.Exchange(ref stereoDifferentFrames, 0)}/" +
$"{Interlocked.Exchange(ref stereoFrames, 0)}";
}
private AVAudioBuffer ProvideInput(uint _, out AVAudioConverterInputStatus status)
{
if (!inputProvided && pendingInput is { } input) { inputProvided = true; status = AVAudioConverterInputStatus.HaveData; return input; }
status = AVAudioConverterInputStatus.NoDataNow; return null!;
}
// One paced 20 ms handoff from the capture ring into the encoder. The render callback calls
// this at its demand rate: the ring absorbs RemoteIO's burst pattern and the small capture vs
// output clock difference, so the sender sees a steady 20 ms feed without a sleep-paced pacer
// thread to stall when iOS coalesces a backgrounded app's wakeups.
private void PumpMicrophoneChunk(VoiceCatClient owner)
{
MicrophoneRoute? route = Volatile.Read(ref microphone);
if (route is null) return;
int required = 960 * route.Channels;
if (route.Ring.Read(microphoneFrame.AsSpan(0, required)) == required &&
ReferenceEquals(route, Volatile.Read(ref microphone)) &&
!owner.Audio.FeedPcm(route.StreamId, microphoneFrame.AsSpan(0, required), route.Channels))
Interlocked.Increment(ref rejectedFeeds);
}
private void ReceiveMixedPcm(ReadOnlySpan<short> pcm) => playbackRing.TryWrite(pcm);
private unsafe int Render(IntPtr isSilence, IntPtr timestamp, uint frameCount, IntPtr outputData)
{
int frames = checked((int)frameCount), requested = checked(frames * 2);
if (requested > renderScratch.Length) return -1;
// This callback is the cadence iOS keeps exact while the app is backgrounded or the device
// is locked, so it owns both managed 20 ms hands-offs: capture into the sender and one mix
// cycle per 20 ms of render demand. Both run allocation-free and without locks or I/O.
VoiceCatClient? owner = Volatile.Read(ref client);
if (owner is null || !IsConnected) microphoneCredit = 0;
else
{
microphoneCredit += frames;
while (microphoneCredit >= 960) { microphoneCredit -= 960; PumpMicrophoneChunk(owner); }
// Top the mix ring up to cover this callback plus its configured target so the read
// below never starves and the buffer keeps its chosen buffering latency.
int deficit = frames + playbackRing.TargetFrames - playbackRing.CountFrames;
for (int produced = 0; produced < deficit; produced += 960) owner.Audio.RunCycle();
}
Span<short> input = renderScratch.AsSpan(0, requested);
int read = playbackRing.Read(input); input[read..].Clear();
int count = Marshal.ReadInt32(outputData), first = IntPtr.Size == 8 ? 8 : 4, stride = IntPtr.Size == 8 ? 16 : 12;
if (count != 2) return -1;
for (int channel = 0; channel < 2; channel++)
{
nint data = Marshal.ReadIntPtr(outputData, first + channel * stride + 8);
var output = new Span<float>((void*)data, frames);
for (int frame = 0; frame < frames; frame++) output[frame] = input[frame * 2 + channel] / 32768f;
}
if (isSilence != IntPtr.Zero) Marshal.WriteByte(isSilence, read == 0 ? (byte)1 : (byte)0);
return 0;
}
internal void Stop()
{
IsConnected = false; Volatile.Write(ref microphone, null);
if (client is { } owner) owner.Audio.MixedPcm -= ReceiveMixedPcm;
DestroyGraph(); client = null; IosAudioRouter.Shared.Deactivate();
}
private void DestroyGraph()
{
if (engineConfigurationObserver is { } observer)
{
Foundation.NSNotificationCenter.DefaultCenter.RemoveObserver(observer);
observer.Dispose(); engineConfigurationObserver = null;
}
if (engine is { } old)
{
if (tapInstalled) old.InputNode.RemoveTapOnBus(0);
old.Stop(); if (source is not null) old.DetachNode(source); old.Dispose();
}
tapInstalled = false; pendingInput = null; inputProvider = null;
convertedMicrophone?.Dispose(); convertedMicrophone = null;
microphoneConverter?.Dispose(); microphoneConverter = null;
microphoneFormat?.Dispose(); microphoneFormat = null;
source?.Dispose(); source = null; outputFormat?.Dispose(); outputFormat = null; engine = null;
}
}
@@ -0,0 +1,248 @@
using AVFoundation;
using Foundation;
using ObjCRuntime;
using UIKit;
namespace VoiceCat.iOS;
internal enum IosAudioPreset { VoiceChat, StereoMicrophone, MonoMicrophone, Advanced }
internal enum IosBluetoothMode { HfpVoice, BuiltInMicA2dp, BuiltInMicSpeaker }
internal enum IosMicMode { Standard, Raw }
internal sealed record IosAudioPort(string Id, string Name, string Type);
internal sealed record IosAudioDataSource(string Id, string Name, IReadOnlyList<AVAudioDataSourcePolarPattern> Patterns);
internal sealed class IosAudioRouter
{
private static readonly NativeHandle SupportedPolarPatternsSelector = Selector.GetHandle("supportedPolarPatterns");
private static readonly NativeHandle SetPreferredPolarPatternSelector = Selector.GetHandle("setPreferredPolarPattern:error:");
internal static IosAudioRouter Shared { get; } = new();
private readonly NSUserDefaults defaults = NSUserDefaults.StandardUserDefaults;
private bool applying;
internal event Action? Changed;
internal IosAudioPreset Preset { get; private set; } = IosAudioPreset.VoiceChat;
internal IosBluetoothMode BluetoothMode { get; private set; } = IosBluetoothMode.HfpVoice;
internal IosMicMode MicMode { get; private set; } = IosMicMode.Standard;
internal bool ForceSpeaker { get; private set; }
internal bool VoiceProcessing { get; private set; } = true;
internal bool AutomaticGainControl { get; private set; } = true;
internal int CaptureChannels { get; private set; } = 1;
internal string? SelectedInputId { get; private set; }
internal string? SelectedDataSourceId { get; private set; }
internal AVAudioDataSourcePolarPattern SelectedPolarPattern { get; private set; } = AVAudioDataSourcePolarPattern.Unknown;
internal IReadOnlyList<IosAudioPort> Inputs { get; private set; } = [];
internal IReadOnlyList<IosAudioPort> Outputs { get; private set; } = [];
internal bool VoiceProcessingAvailable => CaptureChannels == 1 && MicMode == IosMicMode.Standard && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp;
internal bool UsesVoiceProcessing => VoiceProcessing && VoiceProcessingAvailable;
private IosAudioRouter()
{
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.RouteChangeNotification, HandleRouteChange);
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.InterruptionNotification, HandleInterruption);
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.MediaServicesWereResetNotification, _ => Recover("media services reset"));
}
internal void Load()
{
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.preset"), true, out IosAudioPreset preset)) Preset = preset;
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.bluetoothMode"), true, out IosBluetoothMode bluetooth)) BluetoothMode = bluetooth;
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.micMode"), true, out IosMicMode mic)) MicMode = mic;
ForceSpeaker = defaults.BoolForKey("cat.voice.audio.forceSpeaker");
VoiceProcessing = defaults.ValueForKey(new NSString("cat.voice.audio.voiceProcessing")) is null || defaults.BoolForKey("cat.voice.audio.voiceProcessing");
AutomaticGainControl = defaults.ValueForKey(new NSString("cat.voice.audio.agc")) is null || defaults.BoolForKey("cat.voice.audio.agc");
CaptureChannels = defaults.IntForKey("cat.voice.audio.captureChannels") == 2 ? 2 : Preset == IosAudioPreset.StereoMicrophone ? 2 : 1;
SelectedInputId = defaults.StringForKey("cat.voice.audio.inputPortId"); SelectedDataSourceId = defaults.StringForKey("cat.voice.audio.dataSourceId");
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.polarPattern"), true, out AVAudioDataSourcePolarPattern pattern)) SelectedPolarPattern = pattern;
RefreshRoutes();
}
internal void SelectPreset(IosAudioPreset preset)
{
Preset = preset;
if (preset != IosAudioPreset.Advanced)
{
CaptureChannels = preset == IosAudioPreset.StereoMicrophone ? 2 : 1; MicMode = IosMicMode.Standard;
BluetoothMode = preset == IosAudioPreset.VoiceChat ? IosBluetoothMode.HfpVoice : IosBluetoothMode.BuiltInMicA2dp;
if (preset is IosAudioPreset.StereoMicrophone or IosAudioPreset.MonoMicrophone)
SelectedInputId = AVAudioSession.SharedInstance().AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic)?.UID;
else SelectedInputId = null;
// Named presets never carry an Advanced capsule selection across transitions.
// Stereo derives its data source below; mono/voice chat must clear a stale stereo one.
SelectedDataSourceId = null; SelectedPolarPattern = AVAudioDataSourcePolarPattern.Unknown;
if (preset == IosAudioPreset.VoiceChat) ForceSpeaker = true;
}
SaveAndReconfigure();
}
internal void SetForceSpeaker(bool value) { ForceSpeaker = value; SaveAndReconfigure(); }
internal void SetVoiceProcessing(bool value) { VoiceProcessing = value; SaveAndReconfigure(); }
internal void SetAutomaticGainControl(bool value) { AutomaticGainControl = value; SaveAndReconfigure(); }
internal void SetCaptureChannels(int value) { CaptureChannels = value == 2 ? 2 : 1; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
internal void SetBluetoothMode(IosBluetoothMode value) { BluetoothMode = value; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
internal void SetMicMode(IosMicMode value) { MicMode = value; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
internal void SelectInput(string? id) { SelectedInputId = string.IsNullOrEmpty(id) ? null : id; SelectedDataSourceId = null; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
internal void SelectDataSource(string? id) { SelectedDataSourceId = string.IsNullOrEmpty(id) ? null : id; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
internal void SelectPolarPattern(AVAudioDataSourcePolarPattern pattern) { SelectedPolarPattern = pattern; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
internal IReadOnlyList<IosAudioDataSource> DataSources()
{
AVAudioSessionPortDescription? port = AVAudioSession.SharedInstance().AvailableInputs?.FirstOrDefault(value => value.UID == SelectedInputId);
return port?.DataSources?.Select(value => new IosAudioDataSource(value.DataSourceID.ToString(), value.DataSourceName,
value.SupportedPolarPatterns?.ToArray() ?? [])).ToArray() ?? [];
}
internal void RefreshRoutes()
{
AVAudioSession session = AVAudioSession.SharedInstance();
Inputs = session.AvailableInputs?.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray() ?? [];
Outputs = session.CurrentRoute.Outputs.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray();
SelectedInputId ??= session.PreferredInput?.UID; Changed?.Invoke();
}
internal void Apply(bool configureInput)
{
if (applying) return; applying = true;
try
{
AVAudioSession session = AVAudioSession.SharedInstance(); AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptions.MixWithOthers;
if (BluetoothMode == IosBluetoothMode.HfpVoice) options |= AVAudioSessionCategoryOptions.AllowBluetooth | AVAudioSessionCategoryOptions.AllowBluetoothA2DP | AVAudioSessionCategoryOptions.AllowAirPlay;
if (BluetoothMode == IosBluetoothMode.BuiltInMicA2dp) options |= AVAudioSessionCategoryOptions.AllowBluetoothA2DP | AVAudioSessionCategoryOptions.AllowAirPlay;
if (BluetoothMode == IosBluetoothMode.BuiltInMicSpeaker || ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp) options |= AVAudioSessionCategoryOptions.DefaultToSpeaker;
AVAudioSessionMode mode = CaptureChannels == 2 ? AVAudioSessionMode.Default : MicMode == IosMicMode.Raw ? AVAudioSessionMode.Measurement
: BluetoothMode == IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionMode.VideoRecording : AVAudioSessionMode.VoiceChat;
if (!session.SetCategory(AVAudioSessionCategory.PlayAndRecord, mode, options, out NSError? categoryError)) throw new InvalidOperationException(categoryError.LocalizedDescription);
session.SetPreferredSampleRate(48_000, out _); session.SetPreferredIOBufferDuration(0.02, out _);
if (configureInput) ApplyInputSelection(session);
if (!session.SetActive(true, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out NSError? activeError)) throw new InvalidOperationException(activeError.LocalizedDescription);
Console.Error.WriteLine($"VC_ROUTE preset={Preset} requestedCh={CaptureChannels} sessionCh={session.InputNumberOfChannels} " +
$"preferred={session.PreferredInput?.PortName ?? "default"} dataSource={session.InputDataSource?.DataSourceName ?? "default"} " +
$"pattern={session.InputDataSource?.SelectedPolarPattern.ToString() ?? "default"}");
session.OverrideOutputAudioPort(ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionPortOverride.Speaker : AVAudioSessionPortOverride.None, out _); RefreshRoutes();
}
finally { applying = false; }
}
private void ApplyInputSelection(AVAudioSession session)
{
AVAudioSessionPortDescription? port = session.AvailableInputs?.FirstOrDefault(value => value.UID == SelectedInputId);
if (CaptureChannels == 2) port ??= session.AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic);
if (port is null) { if (CaptureChannels == 1) ClearStereoPolarPattern(session); return; }
if (CaptureChannels == 2)
{
// Polar-pattern discovery alone is insufficient on current iPhones: until a stereo
// orientation is requested, the built-in port can expose only its mono Bottom source
// and AVAudioEngine consequently binds a one-channel input node. This is Apple's
// dedicated switch for built-in stereo recording. Portrait matches this portrait UI;
// it also gives Core Audio an unambiguous left/right mapping before graph creation.
if (!session.SetPreferredInputOrientation(AVAudioStereoOrientation.Portrait, out NSError? orientationError))
throw new InvalidOperationException(orientationError?.LocalizedDescription ?? "Could not set the stereo microphone orientation.");
}
AVAudioSessionDataSourceDescription? source = CaptureChannels == 2
? port.DataSources?.FirstOrDefault(SupportsStereoPolarPattern)
: port.DataSources?.FirstOrDefault(value => value.DataSourceID.ToString() == SelectedDataSourceId);
if (CaptureChannels == 1 && source is null) ClearStereoPolarPattern(session);
if (source is not null)
{
if (!port.SetPreferredDataSource(source, out NSError? portError)) throw new InvalidOperationException(portError.LocalizedDescription);
if (CaptureChannels == 2) SetStereoPolarPattern(source);
else if (SelectedPolarPattern != AVAudioDataSourcePolarPattern.Unknown &&
!source.SetPreferredPolarPattern(SelectedPolarPattern, out NSError? patternError))
throw new InvalidOperationException(patternError.LocalizedDescription);
}
if (!session.SetPreferredInput(port, out NSError? inputError)) throw new InvalidOperationException(inputError.LocalizedDescription);
// The working Swift client deliberately does not call
// SetPreferredInputNumberOfChannels: doing so disrupts stereo + A2DP routing. The stereo
// capsule and polar pattern above cause the input node to expose its two-channel format.
if (CaptureChannels == 2 && source is not null &&
!session.SetInputDataSource(source, out NSError? sourceError))
throw new InvalidOperationException(sourceError.LocalizedDescription);
Console.Error.WriteLine($"VC_ROUTE_SELECT port={port.PortName} source={source?.DataSourceName ?? "none"} " +
$"stereoPattern={source is not null && SupportsStereoPolarPattern(source)} " +
$"patterns={string.Join(',', source?.SupportedPolarPatterns?.Select(value => value.ToString()) ?? [])}");
}
// Microsoft.iOS 27 exposes AVAudioSessionPolarPatternStereo as an NSString constant but its
// AVAudioDataSourcePolarPattern smart enum still has no Stereo member. Reading the native
// NSArray and setting the native NSString avoids silently mapping Stereo to Unknown.
private static bool SupportsStereoPolarPattern(AVAudioSessionDataSourceDescription source)
{
NativeHandle handle = NativeMethods.GetObject(source.Handle, SupportedPolarPatternsSelector);
NSArray? patterns = Runtime.GetNSObject<NSArray>(handle);
if (patterns is null) return false;
for (nuint index = 0; index < patterns.Count; index++)
if (patterns.GetItem<NSString>(index)?.IsEqualTo(AVAudioSession.PolarPatternStereo.Handle) == true) return true;
return false;
}
private static void SetStereoPolarPattern(AVAudioSessionDataSourceDescription source)
{
NativeHandle error = NativeHandle.Zero;
if (NativeMethods.SetObject(source.Handle, SetPreferredPolarPatternSelector,
AVAudioSession.PolarPatternStereo.Handle, ref error) == 0)
throw new InvalidOperationException(Runtime.GetNSObject<NSError>(error)?.LocalizedDescription ?? "Could not enable stereo microphone capture.");
}
private static class NativeMethods
{
[System.Runtime.InteropServices.DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")]
internal static extern NativeHandle GetObject(NativeHandle receiver, NativeHandle selector);
[System.Runtime.InteropServices.DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")]
internal static extern byte SetObject(NativeHandle receiver, NativeHandle selector, NativeHandle value, ref NativeHandle error);
}
private static void ClearStereoPolarPattern(AVAudioSession session)
{
session.SetPreferredInputOrientation(AVAudioStereoOrientation.None, out _);
AVAudioSessionPortDescription? builtIn = session.AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic);
if (builtIn is null) return;
foreach (AVAudioSessionDataSourceDescription source in builtIn.DataSources ?? [])
if (source.SelectedPolarPattern == AVAudioDataSourcePolarPattern.Unknown && SupportsStereoPolarPattern(source))
source.SetPreferredPolarPattern(AVAudioDataSourcePolarPattern.Unknown, out _);
}
private void SaveAndReconfigure()
{
defaults.SetString(Preset.ToString(), "cat.voice.audio.preset"); defaults.SetString(BluetoothMode.ToString(), "cat.voice.audio.bluetoothMode");
defaults.SetString(MicMode.ToString(), "cat.voice.audio.micMode"); defaults.SetBool(ForceSpeaker, "cat.voice.audio.forceSpeaker");
defaults.SetBool(VoiceProcessing, "cat.voice.audio.voiceProcessing"); defaults.SetBool(AutomaticGainControl, "cat.voice.audio.agc"); defaults.SetInt(CaptureChannels, "cat.voice.audio.captureChannels");
Set("cat.voice.audio.inputPortId", SelectedInputId); Set("cat.voice.audio.dataSourceId", SelectedDataSourceId); defaults.SetString(SelectedPolarPattern.ToString(), "cat.voice.audio.polarPattern"); defaults.Synchronize();
if (IosAudioEngine.Shared.IsConnected) IosAudioEngine.Shared.Reconfigure(); Changed?.Invoke();
}
private void Set(string key, string? value) { if (value is null) defaults.RemoveObject(key); else defaults.SetString(value, key); }
internal void Deactivate() => AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out _);
internal void EnsureAudio(string reason)
{
if (!IosAudioEngine.Shared.IsConnected) return;
try { IosAudioEngine.Shared.EnsureRunning(); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); }
}
internal void Recover(string reason)
{
RefreshRoutes();
if (!IosAudioEngine.Shared.IsConnected) return;
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{
try { IosAudioEngine.Shared.Reconfigure(); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); }
});
}
private void HandleRouteChange(NSNotification note)
{
NSNumber? value = note.UserInfo?[new NSString("AVAudioSessionRouteChangeReasonKey")] as NSNumber;
AVAudioSessionRouteChangeReason reason = (AVAudioSessionRouteChangeReason)(value?.UInt32Value ?? 0);
RefreshRoutes();
if (reason is AVAudioSessionRouteChangeReason.CategoryChange
or AVAudioSessionRouteChangeReason.Override
or AVAudioSessionRouteChangeReason.RouteConfigurationChange)
return;
Recover($"route change ({reason})");
}
private void HandleInterruption(NSNotification note)
{
NSNumber? type = note.UserInfo?[new NSString("AVAudioSessionInterruptionTypeKey")] as NSNumber;
AVAudioSessionInterruptionType interruption = (AVAudioSessionInterruptionType)(type?.UInt32Value ?? 0);
if (interruption == AVAudioSessionInterruptionType.Ended) Recover("interruption ended");
}
}
@@ -0,0 +1,8 @@
namespace VoiceCat.iOS;
internal static class IosConstants
{
internal const string AppGroup = "group.me.iamtalon.voicecat";
internal const string BroadcastExtension = "me.iamtalon.voicecat.broadcast";
internal const string PasswordService = "me.iamtalon.voicecat.ios";
}
@@ -0,0 +1,25 @@
using System.Runtime.InteropServices;
using Foundation;
namespace VoiceCat.iOS;
internal static partial class IosScreenCapture
{
internal static void Present()
{
if (!OperatingSystem.IsIOSVersionAtLeast(27)) return;
NSUrl? root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup);
if (root?.Path is null) throw new InvalidOperationException("The VoiceCat App Group is unavailable.");
string path = Path.Combine(root.Path, "voicecat", "broadcast_audio.ring"); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (!IsAvailable) throw new InvalidOperationException("Screen audio sharing is unavailable on this device."); PresentNative(path);
}
internal static bool IsAvailable => OperatingSystem.IsIOSVersionAtLeast(27) && Available() != 0;
[LibraryImport("__Internal", EntryPoint = "vc_ios_screen_capture_available")]
private static partial int Available();
[LibraryImport("__Internal", EntryPoint = "vc_ios_screen_capture_present", StringMarshalling = StringMarshalling.Utf8)]
private static partial void PresentNative(string ringPath);
[LibraryImport("__Internal", EntryPoint = "vc_ios_screen_capture_stop")]
internal static partial void Stop();
}
+52
View File
@@ -0,0 +1,52 @@
using Foundation;
using VoiceCat.Audio;
namespace VoiceCat.iOS;
internal sealed class IosSettings
{
private readonly NSUserDefaults values = NSUserDefaults.StandardUserDefaults;
internal AudioInputMode InputMode { get; set; } = AudioInputMode.VoiceActivation;
internal float VadThreshold { get; set; } = 0.025f;
internal float InputGain { get; set; } = 1f;
internal float OutputGain { get; set; } = 1f;
internal bool InputNoiseReduction { get; set; }
internal bool EventSounds { get; set; } = true;
internal bool SpokenEvents { get; set; }
internal float EventVolume { get; set; } = 1f;
internal bool SelfTalkSounds { get; set; }
internal bool PushToTalkSound { get; set; }
internal int AudioBufferMilliseconds { get; set; } = 40;
internal void Load()
{
NSString[] keys = [(NSString)"voice.inputMode", (NSString)"voice.vadThreshold", (NSString)"voice.inputGain",
(NSString)"voice.outputGain", (NSString)"voice.audioBufferMs", (NSString)"feedback.sounds", (NSString)"feedback.volume"];
NSObject[] defaults = [NSNumber.FromInt32((int)AudioInputMode.VoiceActivation), NSNumber.FromFloat(0.025f),
NSNumber.FromFloat(1f), NSNumber.FromFloat(1f), NSNumber.FromInt32(40), NSNumber.FromBoolean(true), NSNumber.FromFloat(1f)];
values.RegisterDefaults(new NSDictionary<NSString, NSObject>(keys, defaults));
int inputMode = checked((int)values.IntForKey("voice.inputMode"));
InputMode = Enum.IsDefined(typeof(AudioInputMode), inputMode) ? (AudioInputMode)inputMode : AudioInputMode.VoiceActivation;
VadThreshold = Math.Clamp(values.FloatForKey("voice.vadThreshold"), 0.001f, 0.1f);
InputGain = Math.Clamp(values.FloatForKey("voice.inputGain"), 0f, 4f);
OutputGain = Math.Clamp(values.FloatForKey("voice.outputGain"), 0f, 1f);
InputNoiseReduction = values.BoolForKey("voice.inputNoiseReduction");
int audioBuffer = checked((int)values.IntForKey("voice.audioBufferMs")); AudioBufferMilliseconds = audioBuffer is 20 or 40 or 60 ? audioBuffer : 40;
EventSounds = values.BoolForKey("feedback.sounds");
SpokenEvents = values.BoolForKey("feedback.speech");
EventVolume = Math.Clamp(values.FloatForKey("feedback.volume"), 0f, 1f);
SelfTalkSounds = values.BoolForKey("feedback.selfTalk");
PushToTalkSound = values.BoolForKey("feedback.ptt");
}
internal void Save()
{
values.SetInt((int)InputMode, "voice.inputMode"); values.SetFloat(VadThreshold, "voice.vadThreshold");
values.SetFloat(InputGain, "voice.inputGain"); values.SetFloat(OutputGain, "voice.outputGain");
values.SetInt(AudioBufferMilliseconds, "voice.audioBufferMs");
values.SetBool(InputNoiseReduction, "voice.inputNoiseReduction"); values.SetBool(EventSounds, "feedback.sounds");
values.SetBool(SpokenEvents, "feedback.speech"); values.SetFloat(EventVolume, "feedback.volume");
values.SetBool(SelfTalkSounds, "feedback.selfTalk"); values.SetBool(PushToTalkSound, "feedback.ptt"); values.Synchronize();
}
}
+90
View File
@@ -0,0 +1,90 @@
using System.Security.Cryptography;
using System.Text;
using Foundation;
using Security;
using VoiceCat.Core;
namespace VoiceCat.iOS;
internal sealed class IosStorage
{
private readonly string directory;
private readonly ServerProfileStore profiles;
internal IosStorage()
{
NSUrl? group = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup);
string root = group?.Path ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
directory = Path.Combine(root, "voicecat");
profiles = new(Path.Combine(directory, "servers.json"));
}
internal string TofuPath => Path.Combine(directory, "tofu_pins.txt");
internal IReadOnlyList<ServerProfile> LoadProfiles()
{
MigrateLegacyFiles();
return profiles.Load();
}
internal void SaveProfiles(IEnumerable<ServerProfile> values) => profiles.Save(values);
private void MigrateLegacyFiles()
{
Directory.CreateDirectory(directory);
string legacy = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "voicecat");
if (Path.GetFullPath(legacy) == Path.GetFullPath(directory) || !Directory.Exists(legacy)) return;
foreach (string name in new[] { "servers.json", "tofu_pins.txt" })
{
string source = Path.Combine(legacy, name), destination = Path.Combine(directory, name);
if (File.Exists(source) && !File.Exists(destination)) File.Copy(source, destination);
}
}
internal string? LoadPassword(ServerProfile profile)
{
string? value = ReadPassword(IosConstants.PasswordService, profile.Id.ToString("D"), IosConstants.AppGroup);
return value ?? (profile.LegacyKeychainTag is { Length: > 0 } tag
? ReadPassword("cat.voice.VoiceCatiOS", tag, IosConstants.AppGroup) ?? ReadPassword("cat.voice.VoiceCatiOS", tag, null)
: null);
}
internal void SavePassword(Guid id, string password)
{
byte[] encoded = Encoding.UTF8.GetBytes(password);
try
{
using var data = NSData.FromArray(encoded);
using var query = PasswordQuery(IosConstants.PasswordService, id.ToString("D"), IosConstants.AppGroup);
using var attributes = new SecRecord { ValueData = data, Label = "VoiceCat server password", Accessible = SecAccessible.AfterFirstUnlock };
SecStatusCode status = SecKeyChain.Update(query, attributes);
if (status == SecStatusCode.ItemNotFound)
{
using var record = PasswordQuery(IosConstants.PasswordService, id.ToString("D"), IosConstants.AppGroup);
record.ValueData = data; record.Label = attributes.Label; record.Accessible = attributes.Accessible;
status = SecKeyChain.Add(record);
}
if (status != SecStatusCode.Success) throw new InvalidOperationException($"Keychain save failed ({status}).");
}
finally { CryptographicOperations.ZeroMemory(encoded); }
}
internal void RemovePassword(Guid id)
{
using var query = PasswordQuery(IosConstants.PasswordService, id.ToString("D"), IosConstants.AppGroup);
SecStatusCode status = SecKeyChain.Remove(query);
if (status is not (SecStatusCode.Success or SecStatusCode.ItemNotFound))
throw new InvalidOperationException($"Keychain removal failed ({status}).");
}
private static string? ReadPassword(string service, string account, string? group)
{
using var query = PasswordQuery(service, account, group);
using SecRecord? result = SecKeyChain.QueryAsRecord(query, out SecStatusCode status);
if (status != SecStatusCode.Success || result?.ValueData is not { } data) return null;
byte[] bytes = data.ToArray();
try { return Encoding.UTF8.GetString(bytes); }
finally { CryptographicOperations.ZeroMemory(bytes); }
}
private static SecRecord PasswordQuery(string service, string account, string? group) => new(SecKind.GenericPassword)
{ Service = service, Account = account, AccessGroup = group };
}
+3
View File
@@ -0,0 +1,3 @@
using UIKit;
UIApplication.Main(args, null, typeof(VoiceCat.iOS.AppDelegate));
@@ -0,0 +1,189 @@
using UIKit;
using VoiceCat.Audio;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
namespace VoiceCat.iOS;
internal sealed class MainTabController : UITabBarController
{
internal MainTabController(AppModel model)
{
UIViewController channels = Wrap(new ChannelsController(model), "Channels", "list.bullet.indent", 0);
UIViewController chat = Wrap(new ChatController(model), "Chat", "message", 1);
UIViewController settings = Wrap(new SettingsController(model), "Settings", "gear", 2);
ViewControllers = [channels, chat, settings];
}
private static UIViewController Wrap(UIViewController content, string title, string image, nint tag)
{
var root = new UIViewController(); UIView rootView = root.View!; rootView.BackgroundColor = UIColor.SystemBackground;
var navigation = new UINavigationController(content); UIView navigationView = navigation.View!; navigationView.TranslatesAutoresizingMaskIntoConstraints = false;
root.AddChildViewController(navigation); rootView.AddSubview(navigationView); navigation.DidMoveToParentViewController(root);
var voice = new VoiceControlsView(AppModel.Shared) { TranslatesAutoresizingMaskIntoConstraints = false }; rootView.AddSubview(voice);
NSLayoutConstraint.ActivateConstraints([navigationView.TopAnchor.ConstraintEqualTo(rootView.TopAnchor), navigationView.LeadingAnchor.ConstraintEqualTo(rootView.LeadingAnchor),
navigationView.TrailingAnchor.ConstraintEqualTo(rootView.TrailingAnchor), navigationView.BottomAnchor.ConstraintEqualTo(voice.TopAnchor),
voice.LeadingAnchor.ConstraintEqualTo(rootView.LeadingAnchor), voice.TrailingAnchor.ConstraintEqualTo(rootView.TrailingAnchor),
voice.BottomAnchor.ConstraintEqualTo(rootView.SafeAreaLayoutGuide.BottomAnchor), voice.HeightAnchor.ConstraintEqualTo(58)]);
root.TabBarItem = new(title, UIImage.GetSystemImage(image), tag); return root;
}
}
internal sealed class VoiceControlsView : UIView
{
private readonly AppModel model; private readonly UIButton join = UIButton.FromType(UIButtonType.System);
private readonly UIButton ptt = UIButton.FromType(UIButtonType.System); private readonly UIButton mute = UIButton.FromType(UIButtonType.System);
private readonly UIButton deafen = UIButton.FromType(UIButtonType.System); private readonly UIProgressView level = new(UIProgressViewStyle.Default);
internal VoiceControlsView(AppModel model) { this.model = model; model.Changed += Refresh; Build(); }
private void Build()
{
BackgroundColor = UIColor.SecondarySystemBackground; join.TouchUpInside += async (_, _) => await Run(model.ToggleVoiceAsync);
ptt.TouchDown += (_, _) => model.SetPushToTalk(true); ptt.TouchUpInside += (_, _) => model.SetPushToTalk(false);
ptt.TouchUpOutside += (_, _) => model.SetPushToTalk(false); ptt.TouchCancel += (_, _) => model.SetPushToTalk(false);
mute.TouchUpInside += (_, _) => model.SetSelfAudio(!(model.Client?.Audio.MicMuted ?? false), model.Client?.Audio.Deafened ?? false);
deafen.TouchUpInside += (_, _) => model.SetSelfAudio(model.Client?.Audio.MicMuted ?? false, !(model.Client?.Audio.Deafened ?? false));
UIStackView stack = new([join, ptt, level, mute, deafen]) { Axis = UILayoutConstraintAxis.Horizontal, Alignment = UIStackViewAlignment.Center,
Distribution = UIStackViewDistribution.Fill, Spacing = 12, TranslatesAutoresizingMaskIntoConstraints = false };
AddSubview(stack); level.WidthAnchor.ConstraintEqualTo(65).Active = true;
NSLayoutConstraint.ActivateConstraints([stack.LeadingAnchor.ConstraintEqualTo(LeadingAnchor, 12), stack.TrailingAnchor.ConstraintEqualTo(TrailingAnchor, -12), stack.CenterYAnchor.ConstraintEqualTo(CenterYAnchor)]); Refresh();
}
private void Refresh()
{
join.SetTitle(model.VoiceJoined ? "Leave Voice" : "Join Voice", UIControlState.Normal); join.AccessibilityLabel = join.Title(UIControlState.Normal); join.Enabled = model.CurrentChannelId != 0;
ptt.SetTitle("Hold to Talk", UIControlState.Normal); ptt.AccessibilityLabel = "Push to talk, hold to transmit"; ptt.Hidden = model.Settings.InputMode != AudioInputMode.PushToTalk; ptt.Enabled = model.VoiceJoined;
bool muted = model.Client?.Audio.MicMuted == true, deafened = model.Client?.Audio.Deafened == true;
mute.SetImage(UIImage.GetSystemImage(muted ? "mic.slash.fill" : "mic.fill"), UIControlState.Normal); mute.AccessibilityLabel = muted ? "Unmute microphone" : "Mute microphone"; mute.Enabled = model.VoiceJoined;
deafen.SetImage(UIImage.GetSystemImage(deafened ? "headphones.slash" : "headphones"), UIControlState.Normal); deafen.AccessibilityLabel = deafened ? "Undeafen" : "Deafen"; deafen.Enabled = model.VoiceJoined;
level.Progress = Math.Clamp(model.MicrophoneLevel * 10, 0, 1); level.AccessibilityLabel = "Microphone level"; level.AccessibilityValue = $"{level.Progress:P0}";
}
private async Task Run(Func<Task> operation) { try { await operation(); } catch (Exception exception) { if (Window?.RootViewController is { } owner) UiHelpers.ShowError(owner, exception); } }
}
internal sealed class ChannelsController : UITableViewController
{
private readonly AppModel model; private IReadOnlyList<(Channel Channel, int Depth)> Visible => Flatten();
internal ChannelsController(AppModel model) { this.model = model; Title = "Channels"; model.Changed += Reload; }
public override void ViewDidLoad()
{
base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "channel");
NavigationItem.RightBarButtonItems = [new("Users", UIBarButtonItemStyle.Plain, (_, _) => NavigationController?.PushViewController(new UsersController(model), true)),
new(UIBarButtonSystemItem.Add, (_, _) => NavigationController?.PushViewController(new ChannelEditorController(model, null), true))]; Reload();
}
private void Reload() { TableView.ReloadData(); NavigationItem.RightBarButtonItems![1].Enabled = model.Client?.Permissions is { } p && (p.IsAdmin || p.CanCreateTempChannel); }
public override nint RowsInSection(UITableView tableView, nint section) => Visible.Count;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
{
(Channel channel, int depth) = Visible[indexPath.Row]; UITableViewCell cell = tableView.DequeueReusableCell("channel", indexPath);
int count = model.Users.Count(user => user.ChannelId == channel.Id); var content = cell.DefaultContentConfiguration;
content.Text = new string(' ', depth * 3) + channel.Name; content.SecondaryText = string.IsNullOrEmpty(channel.Topic) ? $"{count} users" : $"{channel.Topic} • {count} users";
content.Image = UIImage.GetSystemImage(channel.Id == model.CurrentChannelId ? "checkmark.circle.fill" : channel.PasswordProtected ? "lock.fill" : "bubble.left"); cell.ContentConfiguration = content;
cell.AccessibilityLabel = $"{channel.Name}{(channel.Id == model.CurrentChannelId ? ", current" : "")}{(channel.PasswordProtected ? ", password protected" : "")}, {count} users"; return cell;
}
public override async void RowSelected(UITableView tableView, NSIndexPath indexPath)
{
Channel channel = Visible[indexPath.Row].Channel; tableView.DeselectRow(indexPath, true);
if (channel.PasswordProtected) { PromptPassword(channel); return; }
try { await model.JoinChannelAsync(channel.Id); } catch (Exception exception) { UiHelpers.ShowError(this, exception); }
}
public override UISwipeActionsConfiguration? GetTrailingSwipeActionsConfiguration(UITableView tableView, NSIndexPath indexPath)
{
if (model.Client?.Permissions.IsAdmin != true) return null; Channel channel = Visible[indexPath.Row].Channel;
UIContextualAction edit = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Normal, "Edit", (_, _, done) => { NavigationController?.PushViewController(new ChannelEditorController(model, channel), true); done(true); });
UIContextualAction delete = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Destructive, "Delete", (_, _, done) => { ConfirmDelete(channel); done(true); });
delete.BackgroundColor = UIColor.SystemRed; return UISwipeActionsConfiguration.FromActions(channel.Id == 1 ? [edit] : [delete, edit]);
}
private void PromptPassword(Channel channel)
{
UIAlertController prompt = UIAlertController.Create("Channel Password", channel.Name, UIAlertControllerStyle.Alert); prompt.AddTextField(field => { field.SecureTextEntry = true; field.AccessibilityLabel = "Channel password"; });
prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create("Join", UIAlertActionStyle.Default, async _ => { try { await model.JoinChannelAsync(channel.Id, prompt.TextFields?[0].Text ?? ""); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } })); PresentViewController(prompt, true, null);
}
private void ConfirmDelete(Channel channel)
{
UIAlertController alert = UIAlertController.Create("Delete channel?", channel.Name, UIAlertControllerStyle.Alert); alert.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null));
alert.AddAction(UIAlertAction.Create("Delete", UIAlertActionStyle.Destructive, async _ => { try { GenericResult result = await model.RunAdminAsync(client => client.DeleteChannelAsync(channel.Id)); if (!result.Ok) throw new InvalidOperationException(result.Message); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } })); PresentViewController(alert, true, null);
}
private IReadOnlyList<(Channel, int)> Flatten()
{
var result = new List<(Channel, int)>(); void Add(uint parent, int depth) { foreach (Channel value in model.Channels.Where(channel => channel.ParentId == parent).OrderBy(channel => channel.Order).ThenBy(channel => channel.Name)) { result.Add((value, depth)); Add(value.Id, depth + 1); } }
Add(0, 0); return result;
}
}
internal sealed class ChatController : UIViewController
{
private readonly AppModel model; private readonly UITextView log = new(); private readonly UITextField compose = UiHelpers.Field("Message");
internal ChatController(AppModel model) { this.model = model; Title = "Chat"; model.Changed += Refresh; }
public override void ViewDidLoad()
{
base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; log.Editable = false; log.Font = UIFont.PreferredBody; log.AccessibilityLabel = "Chat and activity timeline"; log.TranslatesAutoresizingMaskIntoConstraints = false;
NavigationItem.RightBarButtonItem = new("Private Chats", UIBarButtonItemStyle.Plain, (_, _) => NavigationController?.PushViewController(new PrivateChatsController(model), true));
UIButton send = UIButton.FromType(UIButtonType.System); send.SetTitle("Send", UIControlState.Normal); send.AccessibilityLabel = "Send message"; send.TranslatesAutoresizingMaskIntoConstraints = false; send.TouchUpInside += (_, _) => { model.SendText(compose.Text ?? ""); compose.Text = ""; };
compose.TranslatesAutoresizingMaskIntoConstraints = false; View.AddSubviews(log, compose, send);
NSLayoutConstraint.ActivateConstraints([log.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor), log.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), log.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), compose.TopAnchor.ConstraintEqualTo(log.BottomAnchor, 8), compose.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), compose.BottomAnchor.ConstraintEqualTo(View.KeyboardLayoutGuide.TopAnchor, -8), send.LeadingAnchor.ConstraintEqualTo(compose.TrailingAnchor, 8), send.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), send.CenterYAnchor.ConstraintEqualTo(compose.CenterYAnchor), compose.WidthAnchor.ConstraintGreaterThanOrEqualTo(120)]); Refresh();
}
private void Refresh()
{
IEnumerable<(DateTime Time, string Text)> chat = model.Messages.Where(message => !message.Private).Select(message => (message.Timestamp, $"{message.Sender}: {message.Text}"));
IEnumerable<(DateTime Time, string Text)> activity = model.Activity.Select(value => (value.Timestamp, $"• {value.Text}"));
log.Text = string.Join("\n", chat.Concat(activity).OrderBy(value => value.Time).Select(value => $"[{value.Time:t}] {value.Text}")); if (log.Text.Length > 0) log.ScrollRangeToVisible(new(log.Text.Length - 1, 1));
}
}
internal sealed class PrivateChatsController : UITableViewController
{
private readonly AppModel model;
private IReadOnlyList<(uint Id, string Name, ChatEntry? Latest, bool Online)> Peers
{
get
{
var online = model.Users.Where(user => user.Id != model.SelfUserId).ToDictionary(user => user.Id);
var history = model.Messages.Where(message => message.Private).GroupBy(message => message.PeerUserId).ToDictionary(group => group.Key, group => group.OrderByDescending(message => message.Timestamp).First());
return online.Keys.Concat(history.Keys).Distinct().Select(id =>
{
online.TryGetValue(id, out User? user); history.TryGetValue(id, out ChatEntry? latest);
string name = user?.Nickname ?? latest?.Peer ?? $"User {id}"; return (Id: id, Name: name, Latest: latest, Online: user is not null);
}).OrderByDescending(peer => peer.Latest?.Timestamp ?? DateTime.MinValue).ThenBy(peer => peer.Name).ToArray();
}
}
internal PrivateChatsController(AppModel model) { this.model = model; Title = "Private Chats"; model.Changed += Reload; }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "private-peer"); Reload(); }
public override nint RowsInSection(UITableView tableView, nint section) => Peers.Count;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
{
(uint _, string name, ChatEntry? latest, bool online) = Peers[indexPath.Row]; UITableViewCell cell = tableView.DequeueReusableCell("private-peer", indexPath); var content = cell.DefaultContentConfiguration;
content.Text = name; content.SecondaryText = latest?.Text ?? "Start a conversation"; content.Image = UIImage.GetSystemImage(online ? "person.crop.circle.fill" : "person.crop.circle.badge.xmark"); cell.ContentConfiguration = content;
cell.Accessory = UITableViewCellAccessory.DisclosureIndicator; cell.AccessibilityLabel = $"{name}, {(online ? "online" : "offline")}, {content.SecondaryText}"; return cell;
}
public override void RowSelected(UITableView tableView, NSIndexPath indexPath)
{
(uint id, string name, _, _) = Peers[indexPath.Row]; tableView.DeselectRow(indexPath, true); NavigationController?.PushViewController(new PrivateConversationController(model, id, name), true);
}
private void Reload() { if (IsViewLoaded) { TableView.ReloadData(); TableView.BackgroundView = Peers.Count == 0 ? new UILabel { Text = "No other users or private conversations", TextAlignment = UITextAlignment.Center, AccessibilityLabel = "No other users or private conversations" } : null; } }
}
internal sealed class PrivateConversationController : UIViewController
{
private readonly AppModel model; private readonly uint peerUserId; private readonly string fallbackName;
private readonly UITextView transcript = new(); private readonly UITextField compose = UiHelpers.Field("Private message"); private readonly UIButton send = UIButton.FromType(UIButtonType.System);
private User? Peer => model.Users.FirstOrDefault(user => user.Id == peerUserId);
internal PrivateConversationController(AppModel model, uint peerUserId, string fallbackName)
{ this.model = model; this.peerUserId = peerUserId; this.fallbackName = fallbackName; Title = fallbackName; model.Changed += Refresh; }
public override void ViewDidLoad()
{
base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; transcript.Editable = false; transcript.Font = UIFont.PreferredBody; transcript.AccessibilityLabel = $"Private conversation with {fallbackName}"; transcript.TranslatesAutoresizingMaskIntoConstraints = false;
compose.AccessibilityLabel = $"Message to {fallbackName}"; compose.TranslatesAutoresizingMaskIntoConstraints = false;
send.SetTitle("Send", UIControlState.Normal); send.AccessibilityLabel = $"Send message to {fallbackName}"; send.TranslatesAutoresizingMaskIntoConstraints = false; send.TouchUpInside += (_, _) => Send();
NavigationItem.RightBarButtonItem = new("User", UIBarButtonItemStyle.Plain, (_, _) => NavigationController?.PushViewController(new UserDetailController(model, peerUserId), true)) { AccessibilityLabel = $"Audio and user settings for {fallbackName}" };
View.AddSubviews(transcript, compose, send);
NSLayoutConstraint.ActivateConstraints([transcript.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor), transcript.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), transcript.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), compose.TopAnchor.ConstraintEqualTo(transcript.BottomAnchor, 8), compose.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), compose.BottomAnchor.ConstraintEqualTo(View.KeyboardLayoutGuide.TopAnchor, -8), send.LeadingAnchor.ConstraintEqualTo(compose.TrailingAnchor, 8), send.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), send.CenterYAnchor.ConstraintEqualTo(compose.CenterYAnchor), compose.WidthAnchor.ConstraintGreaterThanOrEqualTo(120)]); Refresh();
}
private void Send() { string text = compose.Text ?? ""; if (string.IsNullOrWhiteSpace(text) || Peer is null) return; model.SendText(text, peerUserId); compose.Text = ""; }
private void Refresh()
{
if (!IsViewLoaded) return; string name = Peer?.Nickname ?? fallbackName; Title = name;
IEnumerable<ChatEntry> messages = model.Messages.Where(message => message.Private && message.PeerUserId == peerUserId).OrderBy(message => message.Timestamp);
transcript.Text = string.Join("\n", messages.Select(message => $"[{message.Timestamp:t}] {(message.SenderId == model.SelfUserId ? "You" : message.Sender)}: {message.Text}"));
if (transcript.Text.Length > 0) transcript.ScrollRangeToVisible(new(transcript.Text.Length - 1, 1));
bool online = Peer is not null; compose.Enabled = online; send.Enabled = online; NavigationItem.RightBarButtonItem!.Enabled = online;
}
}
@@ -0,0 +1,31 @@
using VoiceCat.Core;
using UIKit;
namespace VoiceCat.iOS;
internal sealed class RootViewController : UIViewController
{
private readonly AppModel model;
private UIViewController? current;
internal RootViewController(AppModel model) { this.model = model; model.Changed += Refresh; model.IdentityRequested += ShowIdentity; }
public override void ViewDidLoad() { base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; Refresh(); }
private void Refresh()
{
bool main = model.IsConnected;
if (current is MainTabController && main || current is UINavigationController && !main) return;
UIViewController next = main ? new MainTabController(model) : new UINavigationController(new ServerListController(model));
if (current is not null) { current.WillMoveToParentViewController(null); current.View!.RemoveFromSuperview(); current.RemoveFromParentViewController(); }
UIView rootView = View!, nextView = next.View!;
AddChildViewController(next); nextView.Frame = rootView.Bounds; nextView.AutoresizingMask = UIViewAutoresizing.All; rootView.AddSubview(nextView); next.DidMoveToParentViewController(this); current = next;
}
private void ShowIdentity(ServerIdentityChallenge challenge)
{
UIAlertController alert = UIAlertController.Create(challenge.Status == VoiceCat.Crypto.TofuStatus.Mismatch ? "Server Identity Changed" : "New Server Identity",
$"{challenge.Host}:{challenge.Port}\n\nSHA-256\n{challenge.CertificateFingerprint}", UIAlertControllerStyle.Alert);
alert.AddAction(UIAlertAction.Create("Reject", UIAlertActionStyle.Destructive, _ => model.ResolveIdentity(false)));
alert.AddAction(UIAlertAction.Create("Trust", UIAlertActionStyle.Default, _ => model.ResolveIdentity(true)));
PresentViewController(alert, true, null);
}
}
@@ -0,0 +1,28 @@
using Foundation;
using UIKit;
namespace VoiceCat.iOS;
[Register("SceneDelegate")]
internal sealed class SceneDelegate : UIResponder, IUIWindowSceneDelegate
{
[Export("window")]
public UIWindow? Window { get; set; }
[Export("scene:willConnectToSession:options:")]
public void WillConnect(UIScene scene, UISceneSession session, UISceneConnectionOptions options)
{
if (scene is not UIWindowScene windowScene) return;
Window = new(windowScene) { RootViewController = new RootViewController(AppModel.Shared) };
Window.MakeKeyAndVisible();
}
[Export("sceneDidEnterBackground:")]
public void DidEnterBackground(UIScene scene) => AppModel.Shared.DidEnterBackground();
[Export("sceneWillEnterForeground:")]
public void WillEnterForeground(UIScene scene) => AppModel.Shared.WillEnterForeground();
[Export("sceneDidBecomeActive:")]
public void DidBecomeActive(UIScene scene) => AppModel.Shared.DidBecomeActive();
}
@@ -0,0 +1,67 @@
using VoiceCat.Core;
using UIKit;
namespace VoiceCat.iOS;
internal sealed class ServerListController : UITableViewController
{
private readonly AppModel model;
internal ServerListController(AppModel model) { this.model = model; Title = "Servers"; TabBarItem = new(UITabBarSystemItem.Favorites, 0); }
public override void ViewDidLoad()
{
base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "server");
NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Add, (_, _) => PresentEditor(null)); model.Changed += Reload;
}
public override nint RowsInSection(UITableView tableView, nint section) => model.Profiles.Count;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
{
UITableViewCell cell = tableView.DequeueReusableCell("server", indexPath);
ServerProfile p = model.Profiles[indexPath.Row];
var content = cell.DefaultContentConfiguration; content.Text = p.DisplayName; content.SecondaryText = p.Authentication.ToString(); cell.ContentConfiguration = content;
cell.Accessory = UITableViewCellAccessory.DisclosureIndicator; return cell;
}
public override async void RowSelected(UITableView tableView, NSIndexPath indexPath)
{
tableView.DeselectRow(indexPath, true); try { await model.ConnectAsync(model.Profiles[indexPath.Row]); } catch (Exception e) { UiHelpers.ShowError(this, e); }
}
public override UISwipeActionsConfiguration GetTrailingSwipeActionsConfiguration(UITableView tableView, NSIndexPath indexPath)
{
ServerProfile profile = model.Profiles[indexPath.Row];
UIContextualAction edit = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Normal, "Edit", (_, _, done) => { PresentEditor(profile); done(true); });
UIContextualAction delete = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Destructive, "Delete", (_, _, done) => { model.RemoveProfile(profile); done(true); });
return UISwipeActionsConfiguration.FromActions([delete, edit]);
}
private void PresentEditor(ServerProfile? profile) => PresentViewController(new UINavigationController(new ServerEditorController(model, profile)), true, null);
private void Reload() { TableView.ReloadData(); NavigationItem.Prompt = model.IsConnecting ? model.Status : null; }
}
internal sealed class ServerEditorController : UIViewController
{
private readonly AppModel model; private readonly ServerProfile? existing;
private readonly UITextField host = UiHelpers.Field("Hostname or IP address");
private readonly UITextField port = UiHelpers.Field("Port");
private readonly UISegmentedControl mode = new(["Guest", "Account"]);
private readonly UITextField name = UiHelpers.Field("Nickname or username");
private readonly UITextField password = UiHelpers.Field("Password (optional)", true);
internal ServerEditorController(AppModel model, ServerProfile? existing) { this.model = model; this.existing = existing; Title = existing is null ? "Add Server" : "Edit Server"; }
public override void ViewDidLoad()
{
base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemGroupedBackground; port.KeyboardType = UIKeyboardType.NumberPad;
UIStackView stack = new([host, port, mode, name, password]) { Axis = UILayoutConstraintAxis.Vertical, Spacing = 12, TranslatesAutoresizingMaskIntoConstraints = false };
View.AddSubview(stack); NSLayoutConstraint.ActivateConstraints([stack.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor, 24), stack.LeadingAnchor.ConstraintEqualTo(View.LayoutMarginsGuide.LeadingAnchor), stack.TrailingAnchor.ConstraintEqualTo(View.LayoutMarginsGuide.TrailingAnchor)]);
mode.SelectedSegment = existing?.Authentication == ServerAuthentication.Account ? 1 : 0; host.Text = existing?.Host; port.Text = (existing?.Port ?? 8384).ToString(); name.Text = existing?.Username ?? existing?.Nickname;
NavigationItem.LeftBarButtonItem = new(UIBarButtonSystemItem.Cancel, (_, _) => DismissViewController(true, null));
NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Save, (_, _) => Save());
}
private void Save()
{
try
{
if (!ushort.TryParse(port.Text, out ushort number)) throw new ArgumentException("Enter a valid port.");
ServerAuthentication auth = mode.SelectedSegment == 1 ? ServerAuthentication.Account : ServerAuthentication.Guest;
ServerProfile p = ServerProfile.Create(host.Text ?? "", number, auth, auth == ServerAuthentication.Account ? name.Text : null, auth == ServerAuthentication.Guest ? name.Text : null, existing?.Id);
model.UpsertProfile(p, password.Text); DismissViewController(true, null);
}
catch (Exception e) { UiHelpers.ShowError(this, e); }
}
}
@@ -0,0 +1,99 @@
using AVFoundation;
using AVKit;
using ReplayKit;
using UIKit;
using VoiceCat.Audio;
using Voicecat.V1;
namespace VoiceCat.iOS;
internal sealed class SettingsController : UITableViewController
{
private readonly AppModel model;
internal SettingsController(AppModel model) : base(UITableViewStyle.InsetGrouped) { this.model = model; Title = "Settings"; model.Changed += Reload; IosAudioRouter.Shared.Changed += Reload; }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "setting"); }
private void Reload() => TableView.ReloadData();
public override nint NumberOfSections(UITableView tableView) => 5;
public override nint RowsInSection(UITableView tableView, nint section) => section switch { 0 => 5, 1 => 4, 2 => 5, 3 => model.Client?.Permissions is { } p && (p.IsAdmin || p.CanAdminAccounts) ? 1 : 0, _ => 2 };
public override string? TitleForHeader(UITableView tableView, nint section) => section switch { 0 => "Audio", 1 => "Voice", 2 => "Notifications", 3 => "Administration", _ => "Server" };
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath path)
{
UITableViewCell cell = tableView.DequeueReusableCell("setting", path); cell.AccessoryView = null; cell.Accessory = UITableViewCellAccessory.None;
string title = path.Section switch
{
0 => path.Row switch { 0 => "Audio preset", 1 => "Speaker output", 2 => "Advanced audio", 3 => "Audio buffering", _ => model.ScreenSharing ? "Stop sharing screen audio" : "Share screen audio" },
1 => path.Row switch { 0 => "Input mode", 1 => $"VAD threshold: {model.Settings.VadThreshold:0.000}", 2 => $"Microphone volume: {model.Settings.InputGain:P0}", _ => "Microphone noise reduction" },
2 => path.Row switch { 0 => "Event sounds", 1 => $"Sound volume: {model.Settings.EventVolume:P0}", 2 => "Speak events", 3 => "Own voice activity sounds", _ => "Push-to-talk cue" },
3 => "Manage accounts", _ => path.Row == 0 ? "Disconnect" : "VoiceCat 0.0.1"
};
var content = cell.DefaultContentConfiguration; content.Text = title; content.SecondaryText = path.Section == 0 && path.Row == 0 ? IosAudioRouter.Shared.Preset.ToString() : path.Section == 1 && path.Row == 0 ? model.Settings.InputMode.ToString() : path.Section == 0 && path.Row == 3 ? $"{model.Settings.AudioBufferMilliseconds} ms" : path.Section == 0 && path.Row == 4 && model.ScreenSharing ? "Sharing" : null; cell.ContentConfiguration = content; cell.AccessibilityLabel = title + (content.SecondaryText is null ? "" : ", " + content.SecondaryText);
if (path.Section == 0 && path.Row == 1) cell.AccessoryView = Toggle(IosAudioRouter.Shared.ForceSpeaker, "Speaker output", (_, _) => IosAudioRouter.Shared.SetForceSpeaker(((UISwitch)cell.AccessoryView!).On));
else if (path.Section == 1 && path.Row == 3) cell.AccessoryView = Toggle(model.Settings.InputNoiseReduction, title, (_, _) => { model.Settings.InputNoiseReduction = ((UISwitch)cell.AccessoryView!).On; model.ApplyVoiceSettings(); });
else if (path.Section == 2 && path.Row is 0 or 2 or 3 or 4) { bool value = path.Row switch { 0 => model.Settings.EventSounds, 2 => model.Settings.SpokenEvents, 3 => model.Settings.SelfTalkSounds, _ => model.Settings.PushToTalkSound }; int row = path.Row; cell.AccessoryView = Toggle(value, title, (_, _) => { bool on = ((UISwitch)cell.AccessoryView!).On; if (row == 0) model.Settings.EventSounds = on; else if (row == 2) model.Settings.SpokenEvents = on; else if (row == 3) model.Settings.SelfTalkSounds = on; else model.Settings.PushToTalkSound = on; model.Save(); }); }
else if (path.Section == 0 && path.Row is 2 or 3 || path.Section == 3) cell.Accessory = UITableViewCellAccessory.DisclosureIndicator;
return cell;
}
public override async void RowSelected(UITableView tableView, NSIndexPath path)
{
tableView.DeselectRow(path, true); try
{
if (path.Section == 0 && path.Row == 0) Choice("Audio preset", Enum.GetValues<IosAudioPreset>().Select(value => value.ToString()).ToArray(), index => IosAudioRouter.Shared.SelectPreset(Enum.GetValues<IosAudioPreset>()[index]));
else if (path.Section == 0 && path.Row == 2) NavigationController?.PushViewController(new AdvancedAudioController(), true);
else if (path.Section == 0 && path.Row == 3) Choice("Audio buffering", ["Low latency (20 ms)", "Balanced (40 ms)", "Stable (60 ms)"], index => { model.Settings.AudioBufferMilliseconds = index switch { 0 => 20, 2 => 60, _ => 40 }; model.ApplyVoiceSettings(); });
else if (path.Section == 0 && path.Row == 4) ShowScreenSharing();
else if (path.Section == 1 && path.Row == 0) Choice("Input mode", ["Voice activation", "Push to talk", "Always on"], index => { model.Settings.InputMode = (AudioInputMode)index; model.ApplyVoiceSettings(); });
else if (path.Section == 1 && path.Row == 1) Slider("Voice activation threshold", 0.001f, 0.1f, model.Settings.VadThreshold, value => { model.Settings.VadThreshold = value; model.ApplyVoiceSettings(); });
else if (path.Section == 1 && path.Row == 2) Slider("Microphone volume", 0, 4, model.Settings.InputGain, value => { model.Settings.InputGain = value; model.ApplyVoiceSettings(); });
else if (path.Section == 2 && path.Row == 1) Slider("Sound volume", 0, 1, model.Settings.EventVolume, value => { model.Settings.EventVolume = value; model.Save(); });
else if (path.Section == 3) NavigationController?.PushViewController(new AccountsController(model), true);
else if (path.Section == 4 && path.Row == 0) await model.DisconnectAsync();
}
catch (Exception exception) { UiHelpers.ShowError(this, exception); }
}
private static UISwitch Toggle(bool value, string label, EventHandler changed) { var toggle = new UISwitch { On = value, AccessibilityLabel = label }; toggle.ValueChanged += changed; return toggle; }
private void Choice(string title, string[] values, Action<int> selected) { UIAlertController alert = UIAlertController.Create(title, null, UIAlertControllerStyle.ActionSheet); for (int i = 0; i < values.Length; i++) { int index = i; alert.AddAction(UIAlertAction.Create(values[i], UIAlertActionStyle.Default, _ => selected(index))); } alert.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); alert.PopoverPresentationController!.SourceView = View!; PresentViewController(alert, true, null); }
private void Slider(string title, float minimum, float maximum, float current, Action<float> changed) { var slider = new UISlider(new CoreGraphics.CGRect(16, 48, 238, 28)) { MinValue = minimum, MaxValue = maximum, Value = current, AccessibilityLabel = title }; UIAlertController alert = UIAlertController.Create(title, null, UIAlertControllerStyle.Alert); alert.View!.AddSubview(slider); alert.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); alert.AddAction(UIAlertAction.Create("Apply", UIAlertActionStyle.Default, _ => changed(slider.Value))); PresentViewController(alert, true, null); }
private void ShowScreenSharing()
{
// ScreenCaptureKit is the preferred iOS 27 path, but `IsAvailable` also reflects
// policy restrictions and transient OS state. Keep the bundled ReplayKit provider as
// a compatibility path instead of reporting that capable hardware is unsupported.
if (IosScreenCapture.IsAvailable) { model.ToggleScreenAudio(); return; }
#pragma warning disable CA1422
var picker = new RPSystemBroadcastPickerView(new CoreGraphics.CGRect(0, 0, 60, 60)) { PreferredExtension = IosConstants.BroadcastExtension, ShowsMicrophoneButton = false };
#pragma warning restore CA1422
foreach (UIView view in picker.Subviews) view.AccessibilityLabel = model.ScreenSharing ? "Stop sharing screen audio" : "Share screen audio";
UIAlertController alert = UIAlertController.Create("Screen Audio", "Tap the broadcast button, then choose Start Broadcast.", UIAlertControllerStyle.Alert); UIView alertView = alert.View!; alertView.AddSubview(picker); picker.Center = new(alertView.Bounds.GetMidX(), 110); alert.AddAction(UIAlertAction.Create("Done", UIAlertActionStyle.Cancel, null)); PresentViewController(alert, true, null);
}
}
internal sealed class AdvancedAudioController : UITableViewController
{
private readonly IosAudioRouter router = IosAudioRouter.Shared;
internal AdvancedAudioController() : base(UITableViewStyle.InsetGrouped) { Title = "Advanced Audio"; router.Changed += () => TableView.ReloadData(); }
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "audio"); router.RefreshRoutes(); }
public override nint RowsInSection(UITableView tableView, nint section) => 8;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath path)
{
string title = path.Row switch { 0 => "Input port", 1 => "Microphone orientation", 2 => "Polar pattern", 3 => "Microphone mode", 4 => "Capture channels", 5 => "Bluetooth mode", 6 => "Voice processing and AGC", _ => "Current outputs" };
UITableViewCell cell = tableView.DequeueReusableCell("audio", path); var content = cell.DefaultContentConfiguration; content.Text = title; content.SecondaryText = path.Row switch { 0 => router.Inputs.FirstOrDefault(value => value.Id == router.SelectedInputId)?.Name ?? "Default", 1 => router.DataSources().FirstOrDefault(value => value.Id == router.SelectedDataSourceId)?.Name ?? "Default", 2 => router.SelectedPolarPattern.ToString(), 3 => router.MicMode.ToString(), 4 => router.CaptureChannels == 2 ? "Stereo" : "Mono", 5 => router.BluetoothMode.ToString(), 6 => router.UsesVoiceProcessing ? "AEC/NS on" + (router.AutomaticGainControl ? ", AGC on" : ", AGC off") : "Unavailable or off", _ => string.Join(", ", router.Outputs.Select(value => value.Name)) }; cell.ContentConfiguration = content; cell.AccessibilityLabel = title + ", " + content.SecondaryText; cell.Accessory = path.Row < 7 ? UITableViewCellAccessory.DisclosureIndicator : UITableViewCellAccessory.None; return cell;
}
public override void RowSelected(UITableView tableView, NSIndexPath path) { tableView.DeselectRow(path, true); if (path.Row == 0) Menu("Input port", router.Inputs.Select(value => value.Name).Prepend("Default").ToArray(), index => router.SelectInput(index == 0 ? null : router.Inputs[index - 1].Id)); else if (path.Row == 1) { var values = router.DataSources(); Menu("Microphone orientation", values.Select(value => value.Name).Prepend("Default").ToArray(), index => router.SelectDataSource(index == 0 ? null : values[index - 1].Id)); } else if (path.Row == 2) Menu("Polar pattern", Enum.GetValues<AVAudioDataSourcePolarPattern>().Select(value => value.ToString()).ToArray(), index => router.SelectPolarPattern(Enum.GetValues<AVAudioDataSourcePolarPattern>()[index])); else if (path.Row == 3) Menu("Microphone mode", Enum.GetValues<IosMicMode>().Select(value => value.ToString()).ToArray(), index => router.SetMicMode(Enum.GetValues<IosMicMode>()[index])); else if (path.Row == 4) Menu("Capture channels", ["Mono", "Stereo"], index => router.SetCaptureChannels(index + 1)); else if (path.Row == 5) Menu("Bluetooth mode", Enum.GetValues<IosBluetoothMode>().Select(value => value.ToString()).ToArray(), index => router.SetBluetoothMode(Enum.GetValues<IosBluetoothMode>()[index])); else if (path.Row == 6) Menu("Voice processing", ["Off", "On without AGC", "On with AGC"], index => { router.SetVoiceProcessing(index != 0); router.SetAutomaticGainControl(index == 2); }); }
private void Menu(string title, string[] values, Action<int> selected) { UIAlertController alert = UIAlertController.Create(title, null, UIAlertControllerStyle.ActionSheet); for (int i = 0; i < values.Length; i++) { int index = i; alert.AddAction(UIAlertAction.Create(values[i], UIAlertActionStyle.Default, _ => selected(index))); } alert.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); alert.PopoverPresentationController!.SourceView = View!; PresentViewController(alert, true, null); }
}
internal sealed class AccountsController : UITableViewController
{
private readonly AppModel model; private IReadOnlyList<AccountEntry> accounts = [];
internal AccountsController(AppModel model) { this.model = model; Title = "Accounts"; }
public override async void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "account"); NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Add, (_, _) => PromptCreate()); await Refresh(); }
public override nint RowsInSection(UITableView tableView, nint section) => accounts.Count;
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath path) { AccountEntry account = accounts[path.Row]; UITableViewCell cell = tableView.DequeueReusableCell("account", path); var content = cell.DefaultContentConfiguration; content.Text = account.Username; content.SecondaryText = $"{(account.IsAdmin ? "Administrator" : "Account")} • created {DateTimeOffset.FromUnixTimeMilliseconds((long)account.CreatedAtUnixMs):d}"; cell.ContentConfiguration = content; cell.AccessibilityLabel = content.Text + ", " + content.SecondaryText; return cell; }
public override UISwipeActionsConfiguration GetTrailingSwipeActionsConfiguration(UITableView tableView, NSIndexPath path) { AccountEntry account = accounts[path.Row]; UIContextualAction reset = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Normal, "Reset password", (_, _, done) => { PromptReset(account); done(true); }); UIContextualAction delete = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Destructive, "Delete", (_, _, done) => { ConfirmDelete(account); done(true); }); return UISwipeActionsConfiguration.FromActions([delete, reset]); }
private async Task Refresh() { try { accounts = await model.Client!.ListAccountsAsync(); TableView.ReloadData(); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
private void PromptCreate() => Credentials("Create Account", "Create", async (name, password) => await Execute(client => client.CreateAccountAsync(name, password)));
private void PromptReset(AccountEntry account) => Credentials($"Reset {account.Username}", "Reset", async (_, password) => await Execute(client => client.ResetPasswordAsync(account.Username, password)), account.Username, false);
private void ConfirmDelete(AccountEntry account) { UIAlertController alert = UIAlertController.Create("Delete account?", account.Username, UIAlertControllerStyle.Alert); alert.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); alert.AddAction(UIAlertAction.Create("Delete", UIAlertActionStyle.Destructive, async _ => await Execute(client => client.DeleteAccountAsync(account.Username)))); PresentViewController(alert, true, null); }
private void Credentials(string title, string action, Func<string, string, Task> run, string username = "", bool editName = true) { UIAlertController prompt = UIAlertController.Create(title, null, UIAlertControllerStyle.Alert); prompt.AddTextField(field => { field.Placeholder = "Username"; field.Text = username; field.Enabled = editName; field.AccessibilityLabel = "Username"; }); prompt.AddTextField(field => { field.Placeholder = "Password"; field.SecureTextEntry = true; field.AccessibilityLabel = "New password"; }); prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create(action, UIAlertActionStyle.Default, async _ => await run(prompt.TextFields?[0].Text ?? "", prompt.TextFields?[1].Text ?? ""))); PresentViewController(prompt, true, null); }
private async Task Execute(Func<VoiceCat.Core.VoiceCatClient, Task<GenericResult>> operation) { try { GenericResult result = await model.RunAdminAsync(operation); if (!result.Ok) throw new InvalidOperationException(result.Message); await Refresh(); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } }
}
+20
View File
@@ -0,0 +1,20 @@
using UIKit;
namespace VoiceCat.iOS;
internal static class UiHelpers
{
internal static void ShowError(UIViewController owner, Exception exception) => ShowMessage(owner, "VoiceCat", exception.Message);
internal static void ShowMessage(UIViewController owner, string title, string message)
{
UIAlertController alert = UIAlertController.Create(title, message, UIAlertControllerStyle.Alert);
alert.AddAction(UIAlertAction.Create("OK", UIAlertActionStyle.Default, null)); owner.PresentViewController(alert, true, null);
}
internal static UITextField Field(string placeholder, bool secure = false)
{
var field = new UITextField { Placeholder = placeholder, BorderStyle = UITextBorderStyle.RoundedRect,
SecureTextEntry = secure, TranslatesAutoresizingMaskIntoConstraints = false };
field.AccessibilityLabel = placeholder; return field;
}
}
@@ -0,0 +1,65 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-ios27.0</TargetFramework>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">iossimulator-arm64</RuntimeIdentifier>
<SupportedOSPlatformVersion>18.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ApplicationTitle>VoiceCat</ApplicationTitle>
<ApplicationId>me.iamtalon.voicecat</ApplicationId>
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>0.0.1</ApplicationDisplayVersion>
<CodesignEntitlements>Entitlements.plist</CodesignEntitlements>
<ApplicationManifest>Info.plist</ApplicationManifest>
<TrimMode Condition="'$(Configuration)' == 'Release'">full</TrimMode>
<NoWarn>$(NoWarn);XCODE_27_0_PREVIEW</NoWarn>
<WarningsNotAsErrors>$(WarningsNotAsErrors);IL2104</WarningsNotAsErrors>
<VoiceCatIosStatic>true</VoiceCatIosStatic>
<VoiceCatNativeRid Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">iossimulator-arm64</VoiceCatNativeRid>
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == ''">ios-arm64</VoiceCatNativeRid>
<VoiceCatNativeMediaPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../artifacts/native/runtimes/$(VoiceCatNativeRid)/native/libvoicecat_media.a'))</VoiceCatNativeMediaPath>
<VoiceCatBroadcastSdk Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">iphonesimulator</VoiceCatBroadcastSdk>
<VoiceCatBroadcastSdk Condition="'$(VoiceCatBroadcastSdk)' == ''">iphoneos</VoiceCatBroadcastSdk>
<VoiceCatBroadcastArch>arm64</VoiceCatBroadcastArch>
<VoiceCatBroadcastOutput>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)obj/$(Configuration)/$(TargetFramework)/$(RuntimeIdentifier)/broadcast'))</VoiceCatBroadcastOutput>
<VoiceCatIosCaptureOutput>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)obj/$(Configuration)/$(TargetFramework)/$(RuntimeIdentifier)/capture'))</VoiceCatIosCaptureOutput>
<_ComputePublishLocationDependsOn>VoiceCatPrepareIosNativeAssets;$(_ComputePublishLocationDependsOn)</_ComputePublishLocationDependsOn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../src/VoiceCat.Core/VoiceCat.Core.csproj" AdditionalProperties="VoiceCatIosStatic=true" />
<NativeReference Include="$(VoiceCatNativeMediaPath)" Condition="Exists('$(VoiceCatNativeMediaPath)')">
<Kind>Static</Kind>
<ForceLoad>true</ForceLoad>
</NativeReference>
<LinkerArgument Include="-Wl,-force_load,$(VoiceCatNativeMediaPath)" Condition="Exists('$(VoiceCatNativeMediaPath)')" />
<ReferenceNativeSymbol Include="vcm_opus_version;vcm_opus_error;vcm_encoder_create;vcm_encoder_destroy;vcm_encoder_set;vcm_encoder_get_dred;vcm_encode;vcm_decoder_create;vcm_decoder_destroy;vcm_decode;vcm_dred_decoder_create;vcm_dred_decoder_destroy;vcm_dred_create;vcm_dred_destroy;vcm_dred_parse;vcm_dred_decode;vcm_rnnoise_create;vcm_rnnoise_destroy;vcm_rnnoise_process">
<SymbolType>Function</SymbolType>
</ReferenceNativeSymbol>
<NativeReference Include="$(VoiceCatIosCaptureOutput)/libvoicecat_ios_capture.a">
<Kind>Static</Kind>
<ForceLoad>true</ForceLoad>
<Frameworks Condition="!$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">AVFoundation CoreMedia</Frameworks>
</NativeReference>
<AdditionalAppExtensions Include="$(VoiceCatBroadcastOutput)">
<Name>VoiceCatBroadcast</Name>
<BuildOutput>.</BuildOutput>
<CodesignEntitlements>$(VoiceCatBroadcastOutput)/VoiceCatBroadcast.xcent</CodesignEntitlements>
</AdditionalAppExtensions>
<BundleResource Include="../../../assets/sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
<ImageAsset Include="Assets.xcassets/**" Link="Assets.xcassets/%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<Target Name="VoiceCatBuildBroadcastExtension" BeforeTargets="_ResolveAppExtensionReferences">
<Exec Command="&quot;$(MSBuildThisFileDirectory)build-broadcast-extension.sh&quot; &quot;$(Configuration)&quot; &quot;$(VoiceCatBroadcastSdk)&quot; &quot;$(VoiceCatBroadcastArch)&quot; &quot;$(VoiceCatBroadcastOutput)&quot;" />
</Target>
<Target Name="VoiceCatBuildIosCaptureBridge" BeforeTargets="PrepareForBuild">
<Exec Command="&quot;$(MSBuildThisFileDirectory)build-screen-capture-bridge.sh&quot; &quot;$(VoiceCatBroadcastSdk)&quot; &quot;$(VoiceCatBroadcastArch)&quot; &quot;$(VoiceCatIosCaptureOutput)&quot;" />
</Target>
<Target Name="VoiceCatPrepareIosNativeAssets">
<ItemGroup>
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(Filename)%(Extension)' == 'libvoicecat_media.dylib' or '%(Filename)%(Extension)' == 'voicecat_media.dll' or '%(Filename)%(Extension)' == 'libvoicecat_media.so'" />
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(Filename)%(Extension)' == 'NOTICE.txt' or '%(Filename)%(Extension)' == 'Opus.txt' or '%(Filename)%(Extension)' == 'RNNoise.txt'" />
</ItemGroup>
</Target>
</Project>
+53
View File
@@ -0,0 +1,53 @@
#!/bin/zsh
set -euo pipefail
configuration="${1:?configuration is required}"
sdk="${2:?sdk is required}"
architecture="${3:?architecture is required}"
output="${4:?output directory is required}"
script_dir="${0:A:h}"
project="$script_dir/../../../native/apple/broadcast/VoiceCatBroadcast.xcodeproj"
mkdir -p "$output"
signing=()
# Both keys must always be set. The extension manifest expands these placeholders, and an
# empty CURRENT_PROJECT_VERSION drops CFBundleVersion, which installd rejects. The defaults
# match ApplicationDisplayVersion/ApplicationVersion in VoiceCat.iOS.csproj so that the host
# and extension versions agree when no release build number is supplied.
versioning=(
MARKETING_VERSION="${VOICECAT_DISPLAY_VERSION:-0.0.1}"
CURRENT_PROJECT_VERSION="${VOICECAT_BUILD_NUMBER:-1}"
)
if [[ "$sdk" == "iphonesimulator" ]]; then
signing+=(CODE_SIGNING_ALLOWED=NO)
elif [[ -n "${VOICECAT_BROADCAST_CODESIGN_KEY:-}" && -n "${VOICECAT_BROADCAST_CODESIGN_PROVISION:-}" ]]; then
signing+=(
DEVELOPMENT_TEAM="${VOICECAT_DEVELOPMENT_TEAM:?VOICECAT_DEVELOPMENT_TEAM is required for distribution signing}"
CODE_SIGN_STYLE=Manual
CODE_SIGN_IDENTITY="$VOICECAT_BROADCAST_CODESIGN_KEY"
PROVISIONING_PROFILE_SPECIFIER="$VOICECAT_BROADCAST_CODESIGN_PROVISION"
)
elif [[ -n "${VOICECAT_DEVELOPMENT_TEAM:-}" ]]; then
signing+=(DEVELOPMENT_TEAM="$VOICECAT_DEVELOPMENT_TEAM" CODE_SIGN_STYLE=Automatic)
if [[ "${VOICECAT_ALLOW_PROVISIONING_UPDATES:-}" == "1" ]]; then
signing=(-allowProvisioningUpdates "${signing[@]}")
fi
fi
xcodebuild \
-project "$project" \
-scheme VoiceCatBroadcast \
-configuration "$configuration" \
-sdk "$sdk" \
-arch "$architecture" \
-derivedDataPath "$output/derived" \
CONFIGURATION_BUILD_DIR="$output" \
"${versioning[@]}" \
"${signing[@]}" \
build
xcent=$(find "$output/derived" -name 'VoiceCatBroadcast.appex.xcent' -print -quit)
if [[ -f "$xcent" ]]; then
cp "$xcent" "$output/VoiceCatBroadcast.xcent"
else
cp "$script_dir/../../../native/apple/broadcast/VoiceCatBroadcast.entitlements" "$output/VoiceCatBroadcast.xcent"
fi
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
sdk="$1"
arch="$2"
output="$3"
root="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
mkdir -p "$output"
sdk_path="$(xcrun --sdk "$sdk" --show-sdk-path)"
target="arm64-apple-ios18.0"
[ "$sdk" = "iphonesimulator" ] && target="arm64-apple-ios18.0-simulator"
source="$root/native/ios_screen_capture.m"
flags="-fobjc-arc -fmodules -fmodules-cache-path=$output/module-cache -Wno-unguarded-availability-new"
if [ "$sdk" = "iphonesimulator" ]; then source="$root/native/ios_screen_capture_stub.c"; flags=""; fi
# shellcheck disable=SC2086
xcrun --sdk "$sdk" clang -target "$target" -isysroot "$sdk_path" $flags -Werror -c "$source" -o "$output/ios_screen_capture.o"
xcrun --sdk "$sdk" ar rcs "$output/libvoicecat_ios_capture.a" "$output/ios_screen_capture.o"
@@ -0,0 +1,124 @@
#import <AVFoundation/AVFoundation.h>
#import <CoreMedia/CoreMedia.h>
#import <Foundation/Foundation.h>
#import <ScreenCaptureKit/ScreenCaptureKit.h>
#import <fcntl.h>
#import <stdatomic.h>
#import <sys/mman.h>
#import <sys/stat.h>
#import <unistd.h>
enum { VCRingHeader = 64, VCRingCapacity = 96000 };
@interface VCScreenCaptureBridge : NSObject <SCContentSharingPickerObserver, SCStreamOutput, SCStreamDelegate>
@property(nonatomic) SCStream *stream;
@property(nonatomic) AVAudioConverter *converter;
@property(nonatomic) AVAudioFormat *inputFormat;
@property(nonatomic) AVAudioFormat *outputFormat;
@property(nonatomic) NSString *ringPath;
@property(nonatomic) int ringFd;
@property(nonatomic) void *ringMap;
@end
@implementation VCScreenCaptureBridge
static SCContentSharingPicker *VCSharedPicker(void) {
Class pickerClass = NSClassFromString(@"SCContentSharingPicker");
return pickerClass ? [pickerClass performSelector:@selector(sharedPicker)] : nil;
}
- (instancetype)init {
if ((self = [super init])) {
_ringFd = -1;
_outputFormat = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatInt16 sampleRate:48000 channels:2 interleaved:YES];
}
return self;
}
- (void)openRing {
if (_ringMap || !_ringPath) return;
[[NSFileManager defaultManager] createDirectoryAtPath:[_ringPath stringByDeletingLastPathComponent] withIntermediateDirectories:YES attributes:nil error:nil];
_ringFd = open(_ringPath.fileSystemRepresentation, O_RDWR | O_CREAT, 0644);
size_t size = VCRingHeader + VCRingCapacity * sizeof(int16_t);
if (_ringFd < 0 || ftruncate(_ringFd, (off_t)size) != 0) return;
_ringMap = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, _ringFd, 0);
if (_ringMap == MAP_FAILED) _ringMap = NULL;
if (_ringMap && *(uint32_t *)_ringMap != 0x56434252) {
memset(_ringMap, 0, VCRingHeader); *(uint32_t *)((uint8_t *)_ringMap + 4) = 1; atomic_thread_fence(memory_order_seq_cst); *(uint32_t *)_ringMap = 0x56434252;
}
}
- (void)setRingActive:(BOOL)active {
[self openRing]; if (!_ringMap) return;
if (active) { *(uint32_t *)((uint8_t *)_ringMap + 8) = 2; *(uint32_t *)((uint8_t *)_ringMap + 12) = 48000; }
atomic_thread_fence(memory_order_seq_cst); *(uint32_t *)((uint8_t *)_ringMap + 16) = active ? 1 : 0;
}
- (void)push:(const int16_t *)samples count:(NSUInteger)count {
if (!_ringMap || count == 0 || count > VCRingCapacity) return;
uint8_t *base = _ringMap; uint64_t write = *(uint64_t *)(base + 24); atomic_thread_fence(memory_order_seq_cst); uint64_t read = *(uint64_t *)(base + 32);
if (VCRingCapacity - (write - read) < count) return;
int16_t *data = (int16_t *)(base + VCRingHeader); NSUInteger index = write % VCRingCapacity;
NSUInteger first = MIN(count, VCRingCapacity - index); memcpy(data + index, samples, first * sizeof(int16_t));
if (first < count) memcpy(data, samples + first, (count - first) * sizeof(int16_t));
atomic_thread_fence(memory_order_seq_cst); *(uint64_t *)(base + 24) = write + count;
}
- (void)present:(NSString *)path API_AVAILABLE(ios(27.0)) {
self.ringPath = path; [self openRing]; SCContentSharingPicker *picker = VCSharedPicker();
Class configurationClass = NSClassFromString(@"SCContentSharingPickerConfiguration");
SCContentSharingPickerConfiguration *configuration = [configurationClass new];
if (!picker || !configuration) return;
configuration.showsMicrophoneControl = NO; configuration.showsCameraControl = NO; picker.defaultConfiguration = configuration;
[picker removeObserver:self]; [picker addObserver:self]; picker.active = YES; [picker presentPickerUsingContentStyle:SCShareableContentStyleDisplay];
}
- (void)stop {
[self.stream stopCaptureWithCompletionHandler:^(__unused NSError *error) {}]; self.stream = nil;
SCContentSharingPicker *picker = VCSharedPicker(); [picker removeObserver:self]; picker.active = NO; [self setRingActive:NO];
}
- (void)contentSharingPicker:(SCContentSharingPicker *)picker didUpdateWithFilter:(SCContentFilter *)filter forStream:(SCStream *)stream API_AVAILABLE(ios(27.0)) {
[self stop]; Class configurationClass = NSClassFromString(@"SCStreamConfiguration"); Class streamClass = NSClassFromString(@"SCStream");
SCStreamConfiguration *configuration = [configurationClass new]; if (!configuration || !streamClass) return; configuration.capturesAudio = YES;
configuration.excludesCurrentProcessAudio = YES; configuration.sampleRate = 48000; configuration.channelCount = 2;
configuration.width = 2; configuration.height = 2;
self.stream = [[streamClass alloc] initWithFilter:filter configuration:configuration delegate:self]; NSError *error = nil;
if (![self.stream addStreamOutput:self type:SCStreamOutputTypeAudio sampleHandlerQueue:dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0) error:&error]) { self.stream = nil; return; }
[self.stream startCaptureWithCompletionHandler:^(NSError *captureError) { [self setRingActive:captureError == nil]; }];
}
- (void)contentSharingPicker:(SCContentSharingPicker *)picker didCancelForStream:(SCStream *)stream API_AVAILABLE(ios(27.0)) { [self stop]; }
- (void)contentSharingPickerStartDidFailWithError:(NSError *)error API_AVAILABLE(ios(27.0)) { [self stop]; }
- (void)stream:(SCStream *)stream didStopWithError:(NSError *)error { [self stop]; }
- (void)stream:(SCStream *)stream didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer ofType:(SCStreamOutputType)type {
if (type != SCStreamOutputTypeAudio || !CMSampleBufferDataIsReady(sampleBuffer)) return;
CMAudioFormatDescriptionRef description = CMSampleBufferGetFormatDescription(sampleBuffer); const AudioStreamBasicDescription *asbd = CMAudioFormatDescriptionGetStreamBasicDescription(description);
if (!asbd) return; AVAudioFormat *format = [[AVAudioFormat alloc] initWithStreamDescription:asbd]; AVAudioFrameCount frames = (AVAudioFrameCount)CMSampleBufferGetNumSamples(sampleBuffer);
AVAudioPCMBuffer *input = [[AVAudioPCMBuffer alloc] initWithPCMFormat:format frameCapacity:frames]; input.frameLength = frames;
if (CMSampleBufferCopyPCMDataIntoAudioBufferList(sampleBuffer, 0, frames, input.mutableAudioBufferList) != noErr) return;
if (!self.converter || ![self.inputFormat isEqual:format]) { self.inputFormat = format; self.converter = [[AVAudioConverter alloc] initFromFormat:format toFormat:self.outputFormat]; }
AVAudioFrameCount capacity = (AVAudioFrameCount)ceil(frames * 48000.0 / format.sampleRate) + 64; AVAudioPCMBuffer *output = [[AVAudioPCMBuffer alloc] initWithPCMFormat:self.outputFormat frameCapacity:capacity];
__block BOOL supplied = NO; NSError *error = nil; AVAudioConverterOutputStatus status = [self.converter convertToBuffer:output error:&error withInputFromBlock:^AVAudioBuffer *(AVAudioPacketCount count, AVAudioConverterInputStatus *inputStatus) { if (supplied) { *inputStatus = AVAudioConverterInputStatus_NoDataNow; return nil; } supplied = YES; *inputStatus = AVAudioConverterInputStatus_HaveData; return input; }];
if (status == AVAudioConverterOutputStatus_Error || output.frameLength == 0 || !output.int16ChannelData) return;
[self push:output.int16ChannelData[0] count:output.frameLength * 2];
}
- (void)dealloc {
[self setRingActive:NO]; if (_ringMap) munmap(_ringMap, VCRingHeader + VCRingCapacity * sizeof(int16_t)); if (_ringFd >= 0) close(_ringFd);
}
@end
static VCScreenCaptureBridge *bridge;
int vc_ios_screen_capture_available(void) {
if (@available(iOS 27.0, *)) return VCSharedPicker().available ? 1 : 0;
return 0;
}
void vc_ios_screen_capture_present(const char *ring_path) {
if (@available(iOS 27.0, *)) { if (!bridge) bridge = [VCScreenCaptureBridge new]; [bridge present:[NSString stringWithUTF8String:ring_path]]; }
}
void vc_ios_screen_capture_stop(void) { [bridge stop]; }
@@ -0,0 +1,3 @@
int vc_ios_screen_capture_available(void) { return 0; }
void vc_ios_screen_capture_present(const char *ring_path) { (void)ring_path; }
void vc_ios_screen_capture_stop(void) {}
@@ -0,0 +1,59 @@
{
"version": 1,
"dependencies": {
"net10.0-ios27.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.audio": {
"type": "Project",
"dependencies": {
"VoiceCat.Codec": "[1.0.0, )",
"VoiceCat.Dsp": "[1.0.0, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.core": {
"type": "Project",
"dependencies": {
"VoiceCat.Audio": "[1.0.0, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0-ios27.0/ios-arm64": {},
"net10.0-ios27.0/iossimulator-arm64": {}
}
}
@@ -0,0 +1,58 @@
{
"version": 1,
"dependencies": {
"net10.0-ios27.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.audio": {
"type": "Project",
"dependencies": {
"VoiceCat.Codec": "[1.0.0, )",
"VoiceCat.Dsp": "[1.0.0, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.core": {
"type": "Project",
"dependencies": {
"VoiceCat.Audio": "[1.0.0, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0-ios27.0/iossimulator-arm64": {}
}
}
+53
View File
@@ -0,0 +1,53 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
configuration="${CONFIGURATION:-Debug}"
output="$root/dist/ios-managed-device"
dotnet_host="${VOICECAT_DOTNET:-/usr/local/share/dotnet/dotnet}"
while [ "$#" -gt 0 ]; do
case "$1" in
--configuration) configuration="$2"; shift 2 ;;
--output) output="$2"; shift 2 ;;
-h|--help)
echo "usage: $0 [--configuration Debug|Release] [--output DIR]"
echo "optional env: VOICECAT_DOTNET, VOICECAT_BUILD_NUMBER, VOICECAT_DISPLAY_VERSION, VOICECAT_CODESIGN_KEY, VOICECAT_CODESIGN_PROVISION"
echo "distribution extension env: VOICECAT_DEVELOPMENT_TEAM, VOICECAT_BROADCAST_CODESIGN_KEY, VOICECAT_BROADCAST_CODESIGN_PROVISION"
exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
project="$root/clients/apple/VoiceCat.iOS/VoiceCat.iOS.csproj"
"$root/scripts/build-native-ios.sh"
"$dotnet_host" restore "$project" --locked-mode -p:VoiceCatIosStatic=true
device_lock="obj/packages.device.lock.json"
"$dotnet_host" restore "$project" -r ios-arm64 --no-dependencies \
-p:RestorePackagesWithLockFile=true -p:RestoreLockedMode=false -p:NuGetLockFilePath="$device_lock"
set -- build "$project" -c "$configuration" -r ios-arm64 --no-restore -m:1 \
-p:NuGetLockFilePath="$device_lock"
if [ -n "${VOICECAT_CODESIGN_KEY:-}" ]; then set -- "$@" -p:CodesignKey="$VOICECAT_CODESIGN_KEY"; fi
if [ -n "${VOICECAT_CODESIGN_PROVISION:-}" ]; then set -- "$@" -p:CodesignProvision="$VOICECAT_CODESIGN_PROVISION"; fi
if [ -n "${VOICECAT_BUILD_NUMBER:-}" ]; then set -- "$@" -p:ApplicationVersion="$VOICECAT_BUILD_NUMBER"; fi
"$dotnet_host" "$@"
app="$root/clients/apple/VoiceCat.iOS/bin/$configuration/net10.0-ios27.0/ios-arm64/VoiceCat.iOS.app"
[ -d "$app" ] || { echo "device app was not produced at $app" >&2; exit 1; }
/usr/bin/strings "$app/VoiceCat.Codec.dll" | /usr/bin/grep -q GetMainProgramHandle || {
echo "device codec does not contain the iOS static-library resolver" >&2
exit 1
}
/usr/bin/nm -gU "$app/VoiceCat.iOS" | /usr/bin/grep -q _vcm_decoder_create || {
echo "device executable does not export the statically linked media codec" >&2
exit 1
}
/usr/bin/nm -gU "$app/VoiceCat.iOS" | /usr/bin/grep -q _vcm_rnnoise_create || {
echo "device executable does not export the statically linked RNNoise processor" >&2
exit 1
}
mkdir -p "$output"
/usr/bin/ditto "$app" "$output/VoiceCat.iOS.app"
/usr/bin/codesign --verify --deep --strict "$output/VoiceCat.iOS.app"
echo "$output/VoiceCat.iOS.app"
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
device="${VOICECAT_IOS_DEVICE:-}"
configuration="${CONFIGURATION:-Debug}"
build=true
console=false
while [ "$#" -gt 0 ]; do
case "$1" in
--device) device="$2"; shift 2 ;;
--configuration) configuration="$2"; shift 2 ;;
--no-build) build=false; shift ;;
--console) console=true; shift ;;
--list) xcrun devicectl list devices; exit 0 ;;
-h|--help)
echo "usage: $0 --device NAME-OR-UDID [--configuration Debug|Release] [--no-build] [--console]"
echo "use --list to show devices, or set VOICECAT_IOS_DEVICE"
exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$device" ] || { echo "pass --device NAME-OR-UDID or set VOICECAT_IOS_DEVICE" >&2; exit 2; }
app="$root/dist/ios-managed-device/VoiceCat.iOS.app"
if $build; then "$root/clients/apple/build-ios-device.sh" --configuration "$configuration"; fi
[ -d "$app" ] || { echo "managed device app not found at $app; run build-ios-device.sh first" >&2; exit 1; }
xcrun devicectl device install app --device "$device" "$app"
if $console; then
xcrun devicectl device process launch --device "$device" --terminate-existing --console me.iamtalon.voicecat
else
xcrun devicectl device process launch --device "$device" --terminate-existing me.iamtalon.voicecat
fi
@@ -1,608 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000017 /* VoiceCatiOSApp.swift */; };
BBBB00000000000000000031 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000018 /* AppState.swift */; };
BBBB00000000000000000032 /* SessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000019 /* SessionState.swift */; };
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001A /* AudioSessionManager.swift */; };
BBBB00000000000000000034 /* ServerListStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001B /* ServerListStore.swift */; };
BBBB00000000000000000035 /* SavedServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001C /* SavedServer.swift */; };
BBBB00000000000000000037 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001E /* ServerListView.swift */; };
BBBB00000000000000000038 /* AddServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001F /* AddServerView.swift */; };
BBBB00000000000000000039 /* ServerIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000020 /* ServerIdentityView.swift */; };
BBBB0000000000000000003A /* PasswordPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000021 /* PasswordPromptView.swift */; };
BBBB0000000000000000003B /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000022 /* MainView.swift */; };
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000023 /* ChannelTreeView.swift */; };
BBBB0000000000000000003D /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000024 /* UserListView.swift */; };
BBBB0000000000000000003E /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000025 /* ChatView.swift */; };
BBBB00000000000000000070 /* ChannelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000060 /* ChannelBrowserView.swift */; };
BBBB00000000000000000071 /* ChannelDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000061 /* ChannelDetailView.swift */; };
BBBB00000000000000000072 /* UserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000062 /* UserRow.swift */; };
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000027 /* VoiceControlsView.swift */; };
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000028 /* PerUserTuningView.swift */; };
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000029 /* ChannelEditView.swift */; };
BBBB00000000000000000043 /* BanUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002A /* BanUserView.swift */; };
BBBB00000000000000000044 /* MoveUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002B /* MoveUserView.swift */; };
BBBB00000000000000000045 /* PermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002C /* PermissionsView.swift */; };
BBBB00000000000000000046 /* AccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002D /* AccountsView.swift */; };
BBBB00000000000000000047 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002E /* SettingsView.swift */; };
BBBB0000000000000000004B /* IOSAudioRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002F /* IOSAudioRouter.swift */; };
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */ = {isa = PBXBuildFile; productRef = BBBB0000000000000000004A /* VoiceCatCore */; };
CCCC00000000000000000010 /* BroadcastAudioPump.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000002 /* BroadcastAudioPump.swift */; };
CCCC00000000000000000011 /* BroadcastAudioRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000001 /* BroadcastAudioRing.swift */; };
CCCC00000000000000000012 /* SampleHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000003 /* SampleHandler.swift */; };
CCCC00000000000000000013 /* BroadcastAudioRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000001 /* BroadcastAudioRing.swift */; };
CCCC00000000000000000014 /* VoiceCatBroadcast.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000006 /* VoiceCatBroadcast.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
CCCC00000000000000000036 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = BBBB00000000000000000001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = CCCC00000000000000000030 /* VoiceCatBroadcast */;
remoteInfo = VoiceCatBroadcast;
};
/* End PBXContainerItemProxy section */
/* Begin PBXTargetDependency section */
CCCC00000000000000000035 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = CCCC00000000000000000030 /* VoiceCatBroadcast */;
targetProxy = CCCC00000000000000000036 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXFileReference section */
BBBB00000000000000000012 /* VoiceCatiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceCatiOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
BBBB00000000000000000015 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatiOS.entitlements; sourceTree = "<group>"; };
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceCatiOSApp.swift; sourceTree = "<group>"; };
BBBB00000000000000000018 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
BBBB00000000000000000019 /* SessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionState.swift; sourceTree = "<group>"; };
BBBB0000000000000000001A /* AudioSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionManager.swift; sourceTree = "<group>"; };
BBBB0000000000000000001B /* ServerListStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListStore.swift; sourceTree = "<group>"; };
BBBB0000000000000000001C /* SavedServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedServer.swift; sourceTree = "<group>"; };
BBBB0000000000000000001E /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
BBBB0000000000000000001F /* AddServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerView.swift; sourceTree = "<group>"; };
BBBB00000000000000000020 /* ServerIdentityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerIdentityView.swift; sourceTree = "<group>"; };
BBBB00000000000000000021 /* PasswordPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptView.swift; sourceTree = "<group>"; };
BBBB00000000000000000022 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
BBBB00000000000000000023 /* ChannelTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTreeView.swift; sourceTree = "<group>"; };
BBBB00000000000000000024 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = "<group>"; };
BBBB00000000000000000025 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
BBBB00000000000000000060 /* ChannelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelBrowserView.swift; sourceTree = "<group>"; };
BBBB00000000000000000061 /* ChannelDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelDetailView.swift; sourceTree = "<group>"; };
BBBB00000000000000000062 /* UserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRow.swift; sourceTree = "<group>"; };
BBBB00000000000000000027 /* VoiceControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceControlsView.swift; sourceTree = "<group>"; };
BBBB00000000000000000028 /* PerUserTuningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerUserTuningView.swift; sourceTree = "<group>"; };
BBBB00000000000000000029 /* ChannelEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditView.swift; sourceTree = "<group>"; };
BBBB0000000000000000002A /* BanUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanUserView.swift; sourceTree = "<group>"; };
BBBB0000000000000000002B /* MoveUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveUserView.swift; sourceTree = "<group>"; };
BBBB0000000000000000002C /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = "<group>"; };
BBBB0000000000000000002D /* AccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsView.swift; sourceTree = "<group>"; };
BBBB0000000000000000002E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
BBBB0000000000000000002F /* IOSAudioRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSAudioRouter.swift; sourceTree = "<group>"; };
CCCC00000000000000000001 /* BroadcastAudioRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastAudioRing.swift; sourceTree = "<group>"; };
CCCC00000000000000000002 /* BroadcastAudioPump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastAudioPump.swift; sourceTree = "<group>"; };
CCCC00000000000000000003 /* SampleHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleHandler.swift; sourceTree = "<group>"; };
CCCC00000000000000000004 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
CCCC00000000000000000005 /* VoiceCatBroadcast.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatBroadcast.entitlements; sourceTree = "<group>"; };
CCCC00000000000000000006 /* VoiceCatBroadcast.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VoiceCatBroadcast.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXCopyFilesBuildPhase section */
CCCC00000000000000000037 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
CCCC00000000000000000014 /* VoiceCatBroadcast.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFrameworksBuildPhase section */
BBBB00000000000000000011 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
BBBB00000000000000000002 = {
isa = PBXGroup;
children = (
BBBB00000000000000000003 /* VoiceCatiOS */,
CCCC00000000000000000021 /* VoiceCatBroadcast */,
CCCC00000000000000000020 /* Shared */,
BBBB00000000000000000007 /* Products */,
);
sourceTree = "<group>";
};
BBBB00000000000000000003 /* VoiceCatiOS */ = {
isa = PBXGroup;
children = (
BBBB00000000000000000015 /* Info.plist */,
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */,
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */,
BBBB00000000000000000018 /* AppState.swift */,
BBBB00000000000000000019 /* SessionState.swift */,
BBBB0000000000000000001A /* AudioSessionManager.swift */,
BBBB0000000000000000002F /* IOSAudioRouter.swift */,
BBBB0000000000000000001B /* ServerListStore.swift */,
BBBB0000000000000000001C /* SavedServer.swift */,
CCCC00000000000000000002 /* BroadcastAudioPump.swift */,
BBBB00000000000000000006 /* Views */,
);
path = VoiceCatiOS;
sourceTree = "<group>";
};
CCCC00000000000000000020 /* Shared */ = {
isa = PBXGroup;
children = (
CCCC00000000000000000001 /* BroadcastAudioRing.swift */,
);
path = Shared;
sourceTree = "<group>";
};
CCCC00000000000000000021 /* VoiceCatBroadcast */ = {
isa = PBXGroup;
children = (
CCCC00000000000000000003 /* SampleHandler.swift */,
CCCC00000000000000000004 /* Info.plist */,
CCCC00000000000000000005 /* VoiceCatBroadcast.entitlements */,
);
path = VoiceCatBroadcast;
sourceTree = "<group>";
};
BBBB00000000000000000006 /* Views */ = {
isa = PBXGroup;
children = (
BBBB0000000000000000001E /* ServerListView.swift */,
BBBB0000000000000000001F /* AddServerView.swift */,
BBBB00000000000000000020 /* ServerIdentityView.swift */,
BBBB00000000000000000021 /* PasswordPromptView.swift */,
BBBB00000000000000000022 /* MainView.swift */,
BBBB00000000000000000023 /* ChannelTreeView.swift */,
BBBB00000000000000000024 /* UserListView.swift */,
BBBB00000000000000000025 /* ChatView.swift */,
BBBB00000000000000000060 /* ChannelBrowserView.swift */,
BBBB00000000000000000061 /* ChannelDetailView.swift */,
BBBB00000000000000000062 /* UserRow.swift */,
BBBB00000000000000000027 /* VoiceControlsView.swift */,
BBBB00000000000000000028 /* PerUserTuningView.swift */,
BBBB00000000000000000029 /* ChannelEditView.swift */,
BBBB0000000000000000002A /* BanUserView.swift */,
BBBB0000000000000000002B /* MoveUserView.swift */,
BBBB0000000000000000002C /* PermissionsView.swift */,
BBBB0000000000000000002D /* AccountsView.swift */,
BBBB0000000000000000002E /* SettingsView.swift */,
);
path = Views;
sourceTree = "<group>";
};
BBBB00000000000000000007 /* Products */ = {
isa = PBXGroup;
children = (
BBBB00000000000000000012 /* VoiceCatiOS.app */,
CCCC00000000000000000006 /* VoiceCatBroadcast.appex */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
BBBB00000000000000000008 /* VoiceCatiOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = BBBB0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatiOS" */;
buildPhases = (
BBBB0000000000000000000F /* Sources */,
BBBB00000000000000000010 /* Resources */,
BBBB00000000000000000011 /* Frameworks */,
CCCC00000000000000000037 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
CCCC00000000000000000035 /* PBXTargetDependency */,
);
name = VoiceCatiOS;
packageProductDependencies = (
BBBB0000000000000000004A /* VoiceCatCore */,
);
productName = VoiceCatiOS;
productReference = BBBB00000000000000000012 /* VoiceCatiOS.app */;
productType = "com.apple.product-type.application";
};
CCCC00000000000000000030 /* VoiceCatBroadcast */ = {
isa = PBXNativeTarget;
buildConfigurationList = CCCC00000000000000000032 /* Build configuration list for PBXNativeTarget "VoiceCatBroadcast" */;
buildPhases = (
CCCC00000000000000000031 /* Sources */,
);
buildRules = (
);
dependencies = (
);
name = VoiceCatBroadcast;
productName = VoiceCatBroadcast;
productReference = CCCC00000000000000000006 /* VoiceCatBroadcast.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
BBBB00000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
};
buildConfigurationList = BBBB00000000000000000009 /* Build configuration list for PBXProject "VoiceCatiOS" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = BBBB00000000000000000002;
packageReferences = (
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */,
);
productRefGroup = BBBB00000000000000000007 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
BBBB00000000000000000008 /* VoiceCatiOS */,
CCCC00000000000000000030 /* VoiceCatBroadcast */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
BBBB00000000000000000010 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
BBBB0000000000000000000F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */,
BBBB00000000000000000031 /* AppState.swift in Sources */,
BBBB00000000000000000032 /* SessionState.swift in Sources */,
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */,
BBBB0000000000000000004B /* IOSAudioRouter.swift in Sources */,
BBBB00000000000000000034 /* ServerListStore.swift in Sources */,
BBBB00000000000000000035 /* SavedServer.swift in Sources */,
CCCC00000000000000000010 /* BroadcastAudioPump.swift in Sources */,
CCCC00000000000000000011 /* BroadcastAudioRing.swift in Sources */,
BBBB00000000000000000037 /* ServerListView.swift in Sources */,
BBBB00000000000000000038 /* AddServerView.swift in Sources */,
BBBB00000000000000000039 /* ServerIdentityView.swift in Sources */,
BBBB0000000000000000003A /* PasswordPromptView.swift in Sources */,
BBBB0000000000000000003B /* MainView.swift in Sources */,
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */,
BBBB0000000000000000003D /* UserListView.swift in Sources */,
BBBB0000000000000000003E /* ChatView.swift in Sources */,
BBBB00000000000000000070 /* ChannelBrowserView.swift in Sources */,
BBBB00000000000000000071 /* ChannelDetailView.swift in Sources */,
BBBB00000000000000000072 /* UserRow.swift in Sources */,
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */,
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */,
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */,
BBBB00000000000000000043 /* BanUserView.swift in Sources */,
BBBB00000000000000000044 /* MoveUserView.swift in Sources */,
BBBB00000000000000000045 /* PermissionsView.swift in Sources */,
BBBB00000000000000000046 /* AccountsView.swift in Sources */,
BBBB00000000000000000047 /* SettingsView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
CCCC00000000000000000031 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CCCC00000000000000000012 /* SampleHandler.swift in Sources */,
CCCC00000000000000000013 /* BroadcastAudioRing.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
BBBB0000000000000000000B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
BBBB0000000000000000000C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
BBBB0000000000000000000D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = FJV8L966W4;
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = VoiceCat;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
BBBB0000000000000000000E /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = FJV8L966W4;
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = VoiceCat;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
CCCC00000000000000000033 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = VoiceCatBroadcast/VoiceCatBroadcast.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = FJV8L966W4;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = VoiceCatBroadcast/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.0.1;
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS.broadcast;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
CCCC00000000000000000034 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = VoiceCatBroadcast/VoiceCatBroadcast.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = FJV8L966W4;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = VoiceCatBroadcast/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.0.1;
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS.broadcast;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
BBBB00000000000000000009 /* Build configuration list for PBXProject "VoiceCatiOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BBBB0000000000000000000B /* Debug */,
BBBB0000000000000000000C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
BBBB0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatiOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BBBB0000000000000000000D /* Debug */,
BBBB0000000000000000000E /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
CCCC00000000000000000032 /* Build configuration list for PBXNativeTarget "VoiceCatBroadcast" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CCCC00000000000000000033 /* Debug */,
CCCC00000000000000000034 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
BBBB0000000000000000004A /* VoiceCatCore */ = {
isa = XCSwiftPackageProductDependency;
package = BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */;
productName = VoiceCatCore;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = BBBB00000000000000000001 /* Project object */;
}
@@ -1,78 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BBBB00000000000000000008"
BuildableName = "VoiceCatiOS.app"
BlueprintName = "VoiceCatiOS"
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BBBB00000000000000000008"
BuildableName = "VoiceCatiOS.app"
BlueprintName = "VoiceCatiOS"
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BBBB00000000000000000008"
BuildableName = "VoiceCatiOS.app"
BlueprintName = "VoiceCatiOS"
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -1,189 +0,0 @@
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:
let nick = server.savedUsername.isEmpty ? "iOS User" : server.savedUsername
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() {
session?.stopMicStream()
session?.client.disconnect()
AudioSessionManager.shared.deactivateSession()
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
// Activate the audio session now, while connected NOT lazily when the first
// remote stream arrives. The core opens its miniaudio playback device the moment
// a remote stream starts and only THEN emits .streamStarted; if we waited for
// that event to activate, the playback device would open against an inactive
// AVAudioSession and produce no sound (the "can't hear anyone" bug). Activating
// here guarantees the session is live before any device opens.
do {
try AudioSessionManager.shared.ensureSessionActive()
} catch {
print("Audio session activate on connect failed: \(error)")
}
} else {
connectStatus = "Auth failed: \(ev.result.description)"
showPasswordPrompt = true
}
case .disconnected:
if session == nil { cancelConnect() }
else {
AudioSessionManager.shared.deactivateSession()
session = nil; isConnecting = false
}
case .error:
connectStatus = ev.text ?? "Unknown error"
if session == nil { isConnecting = false }
default:
break
}
}
}
@@ -1,180 +0,0 @@
import AVFoundation
import os
import VoiceCatCore
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager")
@MainActor
final class AudioSessionManager {
static let shared = AudioSessionManager()
weak var client: VoiceCatClient?
/// The stream ID of the currently active local MIC stream, if any. Set by `SessionState`
/// when the user joins/leaves voice so `IOSAudioRouter` can reset the core's capture
/// channel count (e.g. when switching stereo mono) without going through `SessionState`.
var activeMicStreamId: UInt32?
/// Tracks whether WE activated the session. The session must be active whenever the
/// AudioEngine is running (for capture OR playback), so it is activated when any audio
/// needs to play (a remote stream started OR the user joins voice) and only deactivated
/// when disconnecting from the server not when leaving voice, since the user may still
/// want to hear remote audio.
private var isSessionActive = false
func configure() {
// Load stored audio routing preferences and apply them before any audio session
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
// miniaudio (the core) does NOT touch AVAudioSession on iOS.
IOSAudioRouter.shared.loadStoredPreferences()
IOSAudioRouter.shared.applyConfiguration()
IOSAudioRouter.shared.refreshRoutes()
NotificationCenter.default.addObserver(
self, selector: #selector(handleInterruption),
name: AVAudioSession.interruptionNotification, object: nil)
NotificationCenter.default.addObserver(
self, selector: #selector(handleRouteChange),
name: AVAudioSession.routeChangeNotification, object: nil)
}
/// Activate the AVAudioSession if not already active. Call before any audio I/O:
/// when the user joins voice, or when a remote stream starts (so playback works even
/// before the user has joined voice). Idempotent safe to call multiple times.
func ensureSessionActive() throws {
guard !isSessionActive else {
logger.debug("ensureSessionActive — already active, skipping")
return
}
IOSAudioRouter.shared.applyConfiguration()
let session = AVAudioSession.sharedInstance()
try session.setActive(true, options: [])
isSessionActive = true
// For the A2DP output presets, make sure output isn't pinned to the built-in speaker.
// A2DP routing in .playAndRecord is fragile; clearing any speaker override after the
// session is live nudges iOS to honor the Bluetooth output route.
if IOSAudioRouter.shared.wantsA2dpOutput {
try? session.overrideOutputAudioPort(.none)
}
let route = AVAudioSession.sharedInstance().currentRoute
let outputNames = route.outputs.map { $0.portName }.joined(separator: ", ")
let inputNames = route.inputs.map { $0.portName }.joined(separator: ", ")
logger.info("session activated — outputs: [\(outputNames)], inputs: [\(inputNames)]")
logSessionState("after activate")
}
/// Deactivate the AVAudioSession. Call ONLY when disconnecting from the server not
/// when leaving voice (the user may still want to hear remote audio).
func deactivateSession() {
guard isSessionActive else {
logger.debug("deactivateSession — not active, skipping")
return
}
try? AVAudioSession.sharedInstance().setActive(false,
options: .notifyOthersOnDeactivation)
isSessionActive = false
logger.info("session deactivated")
}
/// Log the full AVAudioSession state category, mode, options, and active route.
/// Useful for diagnosing routing issues, e.g. confirming the session stays
/// `PlayAndRecord` with `allowBluetoothA2DP` and keeps the A2DP output route even
/// after the mic engine starts.
func logSessionState(_ when: String) {
let s = AVAudioSession.sharedInstance()
var opts: [String] = []
let o = s.categoryOptions
if o.contains(.mixWithOthers) { opts.append("mixWithOthers") }
if o.contains(.duckOthers) { opts.append("duckOthers") }
if o.contains(.allowBluetoothHFP) { opts.append("allowBluetoothHFP") }
if o.contains(.allowBluetoothA2DP) { opts.append("allowBluetoothA2DP") }
if o.contains(.allowAirPlay) { opts.append("allowAirPlay") }
if o.contains(.defaultToSpeaker) { opts.append("defaultToSpeaker") }
let outs = s.currentRoute.outputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
.joined(separator: ", ")
let ins = s.currentRoute.inputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
.joined(separator: ", ")
logger.info("""
[SESSION @ \(when, privacy: .public)] category=\(s.category.rawValue, privacy: .public) \
mode=\(s.mode.rawValue, privacy: .public) options=[\(opts.joined(separator: ","), privacy: .public)] \
inputs=[\(ins, privacy: .public)] outputs=[\(outs, privacy: .public)] \
inputCh=\(s.inputNumberOfChannels) outputCh=\(s.outputNumberOfChannels)
""")
}
@objc private func handleInterruption(_ notification: Notification) {
guard let info = notification.userInfo,
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else { return }
switch type {
case .began:
logger.info("interruption began — session suspended by system")
isSessionActive = false // system deactivated us
client?.audioSuspend()
case .ended:
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
do {
try AVAudioSession.sharedInstance().setActive(true)
isSessionActive = true
logger.info("interruption ended — session reactivated")
client?.audioResume()
} catch {
logger.error("interruption ended — reactivation failed: \(error.localizedDescription)")
}
}
@unknown default: break
}
}
@objc private func handleRouteChange(_ notification: Notification) {
guard let info = notification.userInfo,
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
else {
logger.warning("routeChange — unknown reason, refreshing only")
IOSAudioRouter.shared.refreshRoutes()
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
return
}
logger.info("routeChange reason=\(self.reasonLabel(reason))")
// Re-apply preferences ONLY on external device plug/unplug. Do NOT re-apply on
// .categoryChange / .routeConfigurationChange those are triggered by our own
// applyConfiguration() calls (setCategory, setPreferredInput, etc.), and re-applying
// would create an infinite notification loop:
// handleRouteChange applyConfiguration setCategory routeChange ...
// That loop burns CPU and cycles the audio session on/off the "glitching" bug.
// IOSAudioRouter.applyConfiguration() also has a re-entrancy guard for synchronous
// notifications, but the reason check here is the primary defense.
if reason == .oldDeviceUnavailable || reason == .newDeviceAvailable {
logger.info("routeChange — external device change, re-applying config")
IOSAudioRouter.shared.applyConfiguration()
}
IOSAudioRouter.shared.refreshRoutes()
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
logSessionState("route change (\(reasonLabel(reason)))")
}
private func reasonLabel(_ reason: AVAudioSession.RouteChangeReason) -> String {
switch reason {
case .oldDeviceUnavailable: return "oldDeviceUnavailable"
case .newDeviceAvailable: return "newDeviceAvailable"
case .categoryChange: return "categoryChange"
case .override: return "override"
case .wakeFromSleep: return "wakeFromSleep"
case .noSuitableRouteForCategory: return "noSuitableRouteForCategory"
case .routeConfigurationChange: return "routeConfigurationChange"
@unknown default: return "unknown"
}
}
}
extension Notification.Name {
static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged")
}
@@ -1,159 +0,0 @@
import Foundation
// BroadcastAudioPump host side of iOS screen-audio sharing (docs/voice.md §9, iOS detail).
//
// Drains the App Group shared-memory ring written by the broadcast upload extension and hands
// whole 20 ms frames to a feed closure (which calls VoiceCatClient.feedPcm). The host app owns
// the SCREEN_AUDIO stream, so screen audio appears as a second stream of the SAME user exactly
// like the Windows/macOS desktop-audio share, and a single session (no separate connection).
//
// It reacts to the extension's Darwin notifications for prompt start/stop, and the drain timer
// also watches the ring's active flag as a safety net if a notification is missed. The ring
// always carries stereo; we downmix to mono when the stream's effective config is mono.
//
// Not @MainActor: the drain runs on a background queue (feedPcm is thread-safe). The start/stop
// callbacks are dispatched to main so they can drive @MainActor SessionState.
final class BroadcastAudioPump {
/// The host should start the SCREEN_AUDIO stream (a broadcast became active).
var onBroadcastStarted: (() -> Void)?
/// The host should stop the SCREEN_AUDIO stream (the broadcast ended).
var onBroadcastFinished: (() -> Void)?
private static let frameSamplesPerChannel = 960 // 20 ms @ 48 kHz
private let queue = DispatchQueue(label: "cat.voice.broadcast.pump")
private var ring: BroadcastAudioRing?
private var timer: DispatchSourceTimer?
private var feed: ((UnsafePointer<Int16>, Int, UInt32) -> Void)?
private var streamChannels = 1
private var ringChannels = 2
private var pending: [Int16] = [] // interleaved at ringChannels width
private var scratch = [Int16](repeating: 0, count: 8192)
private var started = false
// MARK: - Lifecycle (host connect/disconnect)
func start() {
guard !started else { return }
ring = try? BroadcastAudioRing()
started = true
registerDarwin()
// Host reconnected while a broadcast is still running pick it up.
if ring?.isActive == true { onBroadcastStarted?() }
}
func stop() {
guard started else { return }
started = false
unregisterDarwin()
endFeeding()
ring = nil
}
// MARK: - Feeding (driven by the host once the stream is live)
/// Begin draining the ring into `feed`. Called after the SCREEN_AUDIO stream's
/// `.streamStarted` event, when its effective channel count is known.
func beginFeeding(streamChannels: UInt32,
feed: @escaping (UnsafePointer<Int16>, Int, UInt32) -> Void) {
queue.async {
self.streamChannels = max(1, min(2, Int(streamChannels)))
self.ringChannels = max(1, Int(self.ring?.channels ?? 2))
self.feed = feed
self.pending.removeAll(keepingCapacity: true)
self.ring?.drainStale() // discard pre-roll buffered before we were ready
self.startTimer()
}
}
func endFeeding() {
queue.async {
self.timer?.cancel()
self.timer = nil
self.feed = nil
self.pending.removeAll(keepingCapacity: true)
}
}
// MARK: - Drain
private func startTimer() {
let t = DispatchSource.makeTimerSource(queue: queue)
t.schedule(deadline: .now(), repeating: .milliseconds(10), leeway: .milliseconds(2))
t.setEventHandler { [weak self] in self?.drain() }
timer = t
t.resume()
}
private func drain() {
guard let ring, let feed else { return }
// Safety net: broadcast ended but we missed the Darwin note.
if !ring.isActive {
DispatchQueue.main.async { [weak self] in self?.onBroadcastFinished?() }
return
}
while true {
let got = scratch.withUnsafeMutableBufferPointer { ring.read(into: $0) }
if got == 0 { break }
pending.append(contentsOf: scratch[0..<got])
if got < scratch.count { break }
}
emitFrames(feed)
}
private func emitFrames(_ feed: (UnsafePointer<Int16>, Int, UInt32) -> Void) {
let n = Self.frameSamplesPerChannel
let rc = ringChannels
let sc = streamChannels
let inFrame = n * rc
while pending.count >= inFrame {
if sc == rc {
pending.withUnsafeBufferPointer { feed($0.baseAddress!, n, UInt32(sc)) }
} else if sc == 1 && rc == 2 {
var mono = [Int16](repeating: 0, count: n)
pending.withUnsafeBufferPointer { buf in
let p = buf.baseAddress!
for i in 0..<n { mono[i] = Int16((Int(p[i * 2]) + Int(p[i * 2 + 1])) / 2) }
}
mono.withUnsafeBufferPointer { feed($0.baseAddress!, n, 1) }
} else if sc == 2 && rc == 1 {
var stereo = [Int16](repeating: 0, count: n * 2)
pending.withUnsafeBufferPointer { buf in
let p = buf.baseAddress!
for i in 0..<n { stereo[i * 2] = p[i]; stereo[i * 2 + 1] = p[i] }
}
stereo.withUnsafeBufferPointer { feed($0.baseAddress!, n, 2) }
}
pending.removeFirst(inFrame)
}
}
// MARK: - Darwin notifications
private func registerDarwin() {
let observer = Unmanaged.passUnretained(self).toOpaque()
let center = CFNotificationCenterGetDarwinNotifyCenter()
let callback: CFNotificationCallback = { _, observer, name, _, _ in
guard let observer, let name else { return }
let pump = Unmanaged<BroadcastAudioPump>.fromOpaque(observer).takeUnretainedValue()
let raw = name.rawValue as String
DispatchQueue.main.async {
if raw == BroadcastNotification.started { pump.onBroadcastStarted?() }
else if raw == BroadcastNotification.finished { pump.onBroadcastFinished?() }
}
}
CFNotificationCenterAddObserver(center, observer, callback,
BroadcastNotification.started as CFString, nil, .deliverImmediately)
CFNotificationCenterAddObserver(center, observer, callback,
BroadcastNotification.finished as CFString, nil, .deliverImmediately)
}
private func unregisterDarwin() {
CFNotificationCenterRemoveEveryObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
Unmanaged.passUnretained(self).toOpaque())
}
deinit { if started { unregisterDarwin() } }
}
@@ -1,663 +0,0 @@
import AVFoundation
import os
import VoiceCatCore
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter")
/// iOS audio routing layer drives all iOS audio route selection via `AVAudioSession`
/// *before* the core (miniaudio) opens its device. This class is the sole owner of the
/// session: miniaudio does NOT touch `AVAudioSession` on iOS, because the core opens its
/// devices through a `ma_context` configured with `sessionCategory = none` +
/// `noAudioSessionActivate/Deactivate` (see `AudioEngine::make_context_config` in
/// `core/src/audio/audio_engine.cpp`). Without that, miniaudio's default path resets the
/// category to `Record`/`Playback` with no options on every device open, wiping
/// `.allowBluetoothA2DP`/`.playAndRecord` and killing headphone/A2DP output so that
/// config must stay in place. All iOS audio routing (input port selection, mic
/// orientation/polar patterns, HFP vs A2DP, measurement/raw mode, stereo capture) must be
/// driven from here.
///
/// The three user-facing choices:
/// 1. **Input port** which physical input (built-in mic, Bluetooth HFP, headset,
/// USB, AirPlay). For the built-in mic, a sub-selection of **data source**
/// (orientation: front/back/top/bottom) and **polar pattern**
/// (omni/cardioid/subcardioid/bidirectional).
/// 2. **Bluetooth mode** how Bluetooth headsets are handled:
/// - "BT HFP voice" (`.allowBluetoothHFP` + `.allowBluetoothA2DP`): both profiles
/// allowed, iOS picks HFP for two-way mic or A2DP for output-only. Mono, AEC on.
/// - "Built-in Mic + BT A2DP stereo" (`.allowBluetoothA2DP` only): stereo output,
/// built-in mic, no HFP processing.
/// - "Built-in Mic + Speaker" (neither): no Bluetooth at all.
/// 3. **Mic processing mode** Standard (`.voiceChat`: AEC/AGC/HPF on) or
/// Raw/Studio (`.measurement`: all processing off). Raw mode is allowed always
/// but shows a warning when the output route is the speaker (echo risk, no AEC).
///
/// Additionally, **stereo capture** (2-channel built-in mic) is enabled by switching the
/// built-in mic's data source to the `.stereo` polar pattern. The recipe is:
/// `setPreferredDataSource(.stereo source)` + `setPreferredPolarPattern(.stereo)` +
/// `setPreferredInput(built-in mic)` + `setInputDataSource(stereo source)`. The channel
/// count itself must NOT be requested via `setPreferredInputNumberOfChannels(2)` that
/// session-level call collapses the A2DP output route. Instead the core is told to open the
/// device with 2 channels via `vc_set_capture_channels(streamId, 2)`, and the AVAudioSession
/// input anchor (`setPreferredInput` + `setInputDataSource`) keeps the route stable during
/// the HFPA2DP and monostereo reconfigurations.
///
/// Voice Isolation / Wide Spectrum (iOS 17+/18+) are user-toggleable in Control Center
/// for `.voiceChat` apps surfaced as a hint, not a programmatic toggle.
///
/// All choices are persisted in `UserDefaults` and re-applied on route changes.
@MainActor
final class IOSAudioRouter: ObservableObject {
static let shared = IOSAudioRouter()
// MARK: - Published state (drives SettingsView)
@Published var inputPorts: [IOSAudioInputPort] = []
@Published var outputRoutes: [IOSAudioOutputRoute] = []
@Published var bluetoothMode: BluetoothMode = .btHfpVoice
@Published var micMode: MicMode = .standard
@Published var captureChannels: CaptureChannels = .mono
@Published var selectedInputPortId: String?
@Published var selectedDataSourceId: String?
@Published var selectedPolarPattern: String?
@Published var showsRawModeSpeakerWarning: Bool = false
@Published var showsA2dpNoAecWarning: Bool = false
@Published var hasBluetoothDevice: Bool = false
@Published var hasWiredHeadset: Bool = false
/// Audio presets sensible combinations of settings for common scenarios.
/// The app is about choice: users can pick a preset for a quick start, then
/// fine-tune individual settings under "Advanced Audio".
enum AudioPreset: String, CaseIterable, Identifiable {
/// Standard iOS VoIP experience: AEC/AGC/HPF on, mono, system picks best route
/// (BT HFP if connected, wired if connected, speaker if nothing). Always available.
case voiceChat = "Voice Chat"
/// Stereo built-in mic capture (front+back capsules). A2DP output if BT is connected,
/// else built-in speaker / wired. Standard processing (no AEC stereo needs a non-VPIO
/// mode). Always available.
case stereoMic = "Stereo Mic"
/// Maximum fidelity: stereo mic, no AEC/AGC/HPF (raw mode). A2DP output if BT connected,
/// else speaker/wired. Always available. Echo risk on speaker.
case studio = "Studio (No Processing)"
/// Bluetooth HFP: BT mic + BT output, AEC on, mono. Only when BT is connected.
case bluetoothHeadset = "Bluetooth Headset (HFP)"
/// A2DP stereo output + built-in mono mic, AEC off. Only when BT is connected. (For
/// A2DP output + stereo mic, use the Stereo Mic preset while BT is connected.)
case btHeadphonesMonoMic = "BT Headphones + Mono Mic"
/// Wired headset/earpods: wired output + wired mic (or built-in), AEC on, mono.
/// Only when a wired audio device is connected.
case wiredHeadset = "Wired Headset"
/// Settings don't match any preset user has tweaked advanced controls.
case custom = "Custom"
var id: String { rawValue }
var requiresBluetooth: Bool {
switch self {
case .bluetoothHeadset, .btHeadphonesMonoMic: return true
default: return false
}
}
var requiresWired: Bool {
self == .wiredHeadset
}
var bluetoothMode: BluetoothMode {
switch self {
case .voiceChat, .bluetoothHeadset: return .btHfpVoice
// A2DP output when BT is connected; falls back to speaker/wired when it isn't.
case .stereoMic, .studio, .btHeadphonesMonoMic: return .builtInMicBtA2dp
case .wiredHeadset: return .builtInMicSpeaker
case .custom: return .builtInMicSpeaker // placeholder
}
}
var captureChannels: CaptureChannels {
switch self {
case .stereoMic, .studio: return .stereo
default: return .mono
}
}
var micMode: MicMode {
switch self {
case .studio: return .raw
default: return .standard
}
}
/// Whether this preset explicitly selects the built-in mic port.
var usesBuiltInMic: Bool {
switch self {
case .stereoMic, .studio, .btHeadphonesMonoMic: return true
default: return false
}
}
}
enum BluetoothMode: String, CaseIterable, Identifiable {
case btHfpVoice = "BT HFP Voice"
case builtInMicBtA2dp = "Built-in Mic + BT A2DP"
case builtInMicSpeaker = "Built-in Mic + Speaker"
var id: String { rawValue }
}
enum MicMode: String, CaseIterable, Identifiable {
case standard = "Standard"
case raw = "Raw / Studio"
var id: String { rawValue }
}
enum CaptureChannels: String, CaseIterable, Identifiable {
case mono = "Mono"
case stereo = "Stereo"
var id: String { rawValue }
var channelCount: UInt32 { self == .stereo ? 2 : 1 }
}
// MARK: - UserDefaults keys
private let kBluetoothMode = "cat.voice.audio.bluetoothMode"
private let kMicMode = "cat.voice.audio.micMode"
private let kCaptureChannels = "cat.voice.audio.captureChannels"
private let kInputPortId = "cat.voice.audio.inputPortId"
private let kDataSourceId = "cat.voice.audio.dataSourceId"
private let kPolarPattern = "cat.voice.audio.polarPattern"
private let kPreset = "cat.voice.audio.preset"
/// Re-entrancy guard: setCategory/setPreferredInput/etc. trigger route-change
/// notifications synchronously on the same thread. Without this guard,
/// handleRouteChange applyConfiguration setCategory route-change notification
/// handleRouteChange applyConfiguration ... creates an infinite loop that
/// burns CPU and cycles the audio session on/off (the "glitching" bug).
private var isApplyingConfiguration = false
private init() {}
// MARK: - Load / refresh from AVAudioSession
/// Refresh the published input port list and output route list from the current
/// AVAudioSession state. Call after any route change or when the settings view appears.
func refreshRoutes() {
let session = AVAudioSession.sharedInstance()
let currentInput = session.preferredInput
let currentDataSource = currentInput?.preferredDataSource?.dataSourceID ?? nil
let currentPolarPattern = currentInput?.preferredDataSource?.preferredPolarPattern?.rawValue
inputPorts = (session.availableInputs ?? []).map { port in
let dataSources = port.dataSources?.map { ds in
IOSAudioDataSource(
id: String(describing: ds.dataSourceID),
name: ds.dataSourceName,
polarPatterns: ds.supportedPolarPatterns?.map { $0.rawValue },
isSelected: currentDataSource == ds.dataSourceID,
selectedPolarPattern: currentPolarPattern
)
}
return IOSAudioInputPort(
id: port.uid,
name: port.portName,
portType: port.portType.rawValue,
dataSources: dataSources,
isSelected: currentInput?.uid == port.uid
)
}
outputRoutes = session.currentRoute.outputs.map { port in
IOSAudioOutputRoute(
id: port.uid,
name: port.portName,
portType: port.portType.rawValue
)
}
if selectedInputPortId == nil {
selectedInputPortId = currentInput?.uid ?? inputPorts.first?.id
}
if selectedDataSourceId == nil {
selectedDataSourceId = currentDataSource.map { String(describing: $0) }
}
if selectedPolarPattern == nil {
selectedPolarPattern = currentPolarPattern
}
updateWarnings()
detectAudioDevices()
}
/// Detect connected audio devices Bluetooth (A2DP/HFP) and wired (headphones,
/// headset mic, USB audio). Drives which presets are shown: BT presets only appear
/// when a BT device is connected, wired presets only when a wired device is connected.
/// This avoids confusing users with irrelevant options.
private func detectAudioDevices() {
let session = AVAudioSession.sharedInstance()
let route = session.currentRoute
let inputs = session.availableInputs ?? []
// Bluetooth: check current route + available inputs
let hasBTOutput = route.outputs.contains {
$0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP
}
let hasBTInput = route.inputs.contains { $0.portType == .bluetoothHFP }
let hasBTAvailable = inputs.contains {
$0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP
}
let wasBT = hasBluetoothDevice
hasBluetoothDevice = hasBTOutput || hasBTInput || hasBTAvailable
if hasBluetoothDevice != wasBT {
logger.info("bluetooth device \(self.hasBluetoothDevice ? "connected" : "disconnected")")
}
// Wired: headphones, headset mic, USB audio (earpods, Lightning/USB-C headsets)
let hasWiredOutput = route.outputs.contains {
$0.portType == .headphones || $0.portType == .usbAudio
}
let hasWiredInput = route.inputs.contains {
$0.portType == .headsetMic || $0.portType == .usbAudio
}
let hasWiredAvailable = inputs.contains {
$0.portType == .headphones || $0.portType == .headsetMic || $0.portType == .usbAudio
}
let wasWired = hasWiredHeadset
hasWiredHeadset = hasWiredOutput || hasWiredInput || hasWiredAvailable
if hasWiredHeadset != wasWired {
logger.info("wired headset \(self.hasWiredHeadset ? "connected" : "disconnected")")
}
}
/// The presets available given the current device connection state.
/// Always includes Voice Chat, Stereo Mic, Studio, and Custom. BT presets only when
/// a Bluetooth device is connected. Wired preset only when a wired device is connected.
var availablePresets: [AudioPreset] {
AudioPreset.allCases.filter { preset in
if preset == .custom { return true }
if preset.requiresBluetooth && !hasBluetoothDevice { return false }
if preset.requiresWired && !hasWiredHeadset { return false }
return true
}
}
/// Which preset matches the current settings, or .custom if nothing matches.
/// Checks device-specific presets first (BT, wired) so that e.g. when BT is connected
/// and settings match "Bluetooth Headset", it returns that instead of the equivalent
/// "Voice Chat" (which has the same bluetoothMode/micMode/channels but is more general).
var activePreset: AudioPreset {
// Check device-specific presets first (most specific least specific)
let order: [AudioPreset] = [
.bluetoothHeadset, .btHeadphonesMonoMic,
.wiredHeadset,
.voiceChat, .stereoMic, .studio,
]
for preset in order {
if bluetoothMode == preset.bluetoothMode
&& captureChannels == preset.captureChannels
&& micMode == preset.micMode {
// Don't match a BT preset if no BT is connected fall through to Voice Chat
if preset.requiresBluetooth && !hasBluetoothDevice { continue }
if preset.requiresWired && !hasWiredHeadset { continue }
return preset
}
}
return .custom
}
// MARK: - Apply configuration
/// Apply the full audio configuration to AVAudioSession. Call this before the core
/// opens its capture device (i.e. before `startMicStream` `activateForStreaming`).
/// Re-entrant-safe: if a route-change notification fires synchronously during a
/// `setCategory`/`setPreferredInput` call, the guard prevents re-entry.
func applyConfiguration() {
guard !isApplyingConfiguration else {
logger.debug("applyConfiguration skipped — already applying (re-entrancy guard)")
return
}
isApplyingConfiguration = true
defer { isApplyingConfiguration = false }
let session = AVAudioSession.sharedInstance()
// 1. Build category options from bluetooth mode.
// .mixWithOthers is ALWAYS set it keeps other audio (notably VoiceOver, which a
// blind user needs to operate the phone) audible while our session is active. Never
// drop it.
// .defaultToSpeaker is set ONLY for the speaker preset. It forces output to the
// built-in speaker instead of the receiver but it also actively breaks A2DP
// routing in .playAndRecord, so it must NOT be set for the A2DP or HFP presets.
// .allowAirPlay is added to the Bluetooth presets so AirPlay output also works.
var options: AVAudioSession.CategoryOptions = [.mixWithOthers]
switch bluetoothMode {
case .btHfpVoice:
// Voice Chat: allow BOTH HFP and A2DP, let iOS pick the right profile for the
// connected device. HFP and A2DP must NOT be made mutually exclusive (HFP-only)
// that blocks A2DP headphones from receiving audio. HFP is preferred (the system
// uses it when a two-way mic path is needed); A2DP stays available for output-only.
options.insert(.allowBluetoothHFP)
options.insert(.allowBluetoothA2DP)
options.insert(.allowAirPlay)
case .builtInMicBtA2dp:
// A2DP output only (no HFP). With HFP disabled the Bluetooth device can only be
// an OUTPUT (A2DP), so the system routes the mic to the built-in mic exactly
// what we want for "built-in mic + A2DP output", in either mono OR stereo.
options.insert(.allowBluetoothA2DP)
options.insert(.allowAirPlay)
case .builtInMicSpeaker:
// Built-in mic + speaker/wired output only. Prefer speaker over the receiver.
options.insert(.defaultToSpeaker)
}
// 2. Set category + mode, chosen per scenario:
// - Stereo capture: .default .voiceChat (the AEC/VPIO path) forces MONO, so stereo
// is only possible in a non-VPIO mode. .default supports multi-capsule stereo AND
// keeps the A2DP output route alive.
// - Mono raw/studio: .measurement all system processing off.
// - Mono + A2DP output: .videoRecording keeps A2DP output without VPIO (no AEC).
// - Mono standard (HFP or speaker): .voiceChat hardware AEC/AGC/HPF.
let mode: AVAudioSession.Mode
if captureChannels == .stereo {
mode = .default
} else if micMode == .raw {
mode = .measurement
} else if bluetoothMode == .builtInMicBtA2dp {
mode = .videoRecording
} else {
mode = .voiceChat
}
do {
try session.setCategory(.playAndRecord, mode: mode, options: options)
logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue), ch=\(self.captureChannels.rawValue), options=\(self.optionsLabel(options))")
} catch {
logger.error("setCategory failed: \(error.localizedDescription)")
}
// 3. Input & mic-capsule configuration.
if captureChannels == .stereo {
// Stereo: enable the built-in mic's .stereo polar pattern AND anchor the input
// route explicitly via setPreferredInput + setInputDataSource. With HFP disabled
// the system routes input to the built-in mic, but without the explicit
// preferred-input anchor the route can collapse during the mode switch
// (.voiceChat .default) and the output dies. The channel count is requested by
// miniaudio at the audio-unit level (vc_set_capture_channels), NOT via
// setPreferredInputNumberOfChannels(2) that call collapses the A2DP output route.
configureStereoCapture(session: session)
} else if let portId = selectedInputPortId, !portId.isEmpty,
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
// Mono with an explicit input-port selection (advanced settings).
do {
try session.setPreferredInput(port)
logger.info("setPreferredInput ok — \(port.portName)")
} catch {
logger.error("setPreferredInput failed: \(error.localizedDescription)")
}
configureMonoCapture(session: session, port: port)
} else {
// Mono, system-default input. Still clear any leftover .stereo capsule from a
// prior stereo session so we actually return to mono.
clearStereoPolarPattern(session: session)
}
updateWarnings()
}
/// Enable 2-channel capture on the built-in mic. The recipe that achieves stereo mic +
/// A2DP Bluetooth output simultaneously:
/// 1. `setPreferredDataSource(stereoSource)` on the built-in mic port
/// 2. `setPreferredPolarPattern(.stereo)` on that data source
/// 3. `setPreferredInput(builtIn)` anchor the input route explicitly. Without this
/// anchor the route can collapse during the mode switch (.voiceChat .default).
/// 4. `setInputDataSource(stereoSource)` commit the data source at the session level
/// The channel count itself is requested by miniaudio at the audio-unit level via
/// `vc_set_capture_channels(2)`. We must NOT call `setPreferredInputNumberOfChannels(2)`
/// that session-level call collapses the A2DP output route.
private func configureStereoCapture(session: AVAudioSession) {
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
else {
logger.warning("stereo requested but no built-in mic available — staying mono")
return
}
guard let stereoSource = builtIn.dataSources?.first(where: {
$0.supportedPolarPatterns?.contains(.stereo) == true
}) else {
logger.warning("stereo requested but built-in mic has no .stereo data source — staying mono")
return
}
do {
try builtIn.setPreferredDataSource(stereoSource)
try stereoSource.setPreferredPolarPattern(.stereo)
// Anchor the input route explicitly; without it the route can collapse during
// the mode switch (.voiceChat .default) and the A2DP output dies.
try session.setPreferredInput(builtIn)
// Commit the data source at the session level. setPreferredDataSource alone only
// sets the port-level preference; setInputDataSource makes it the active source.
try session.setInputDataSource(stereoSource)
logger.info("stereo capsule enabled — source=\(stereoSource.dataSourceName), pattern=.stereo, input anchored")
} catch {
logger.error("stereo capsule setup failed: \(error.localizedDescription)")
}
}
/// Configure mono capture on an explicitly selected port: apply the user's chosen data source
/// (orientation) and polar pattern, resetting any prior `.stereo` pattern back to default.
private func configureMonoCapture(session: AVAudioSession, port: AVAudioSessionPortDescription) {
guard let dataSourceId = selectedDataSourceId, !dataSourceId.isEmpty,
let dataSource = port.dataSources?.first(where: {
String(describing: $0.dataSourceID) == dataSourceId
}) else {
// No explicit capsule choice make sure we're not stuck on a prior .stereo pattern.
clearStereoPolarPattern(session: session)
return
}
do {
try port.setPreferredDataSource(dataSource)
logger.info("setPreferredDataSource ok — \(dataSource.dataSourceName)")
} catch {
logger.error("setPreferredDataSource failed: \(error.localizedDescription)")
}
if let polarPattern = selectedPolarPattern, !polarPattern.isEmpty {
let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern)
try? dataSource.setPreferredPolarPattern(pattern)
logger.info("setPreferredPolarPattern ok — \(polarPattern)")
} else {
// Clear any prior .stereo selection so mono capture returns to a mono capsule.
try? dataSource.setPreferredPolarPattern(nil)
}
}
/// Reset any built-in-mic data source that's currently on the `.stereo` polar pattern back to
/// the default (mono) pattern. Used when switching from a stereo session back to mono with no
/// explicit capsule selection, so the prior stereo capsule doesn't linger.
private func clearStereoPolarPattern(session: AVAudioSession) {
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
else { return }
for ds in builtIn.dataSources ?? [] where ds.selectedPolarPattern == .stereo {
try? ds.setPreferredPolarPattern(nil)
}
}
private func modeLabel(_ mode: AVAudioSession.Mode) -> String {
switch mode {
case .voiceChat: return "voiceChat"
case .measurement: return "measurement"
case .videoRecording: return "videoRecording"
case .default: return "default"
default: return "other"
}
}
private func optionsLabel(_ opts: AVAudioSession.CategoryOptions) -> String {
var parts: [String] = []
if opts.contains(.defaultToSpeaker) { parts.append("defaultToSpeaker") }
if opts.contains(.mixWithOthers) { parts.append("mixWithOthers") }
if opts.contains(.allowBluetoothHFP) { parts.append("allowBluetoothHFP") }
if opts.contains(.allowBluetoothA2DP) { parts.append("allowBluetoothA2DP") }
return parts.joined(separator: ",")
}
/// Apply stored preferences from UserDefaults. Called at app launch (before any
/// audio session activation).
func loadStoredPreferences() {
if let raw = UserDefaults.standard.string(forKey: kBluetoothMode),
let mode = BluetoothMode(rawValue: raw) {
bluetoothMode = mode
}
if let raw = UserDefaults.standard.string(forKey: kMicMode),
let mode = MicMode(rawValue: raw) {
micMode = mode
}
if let raw = UserDefaults.standard.string(forKey: kCaptureChannels),
let ch = CaptureChannels(rawValue: raw) {
captureChannels = ch
}
selectedInputPortId = UserDefaults.standard.string(forKey: kInputPortId)
selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId)
selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern)
}
/// Persist current selections to UserDefaults.
func savePreferences() {
UserDefaults.standard.set(bluetoothMode.rawValue, forKey: kBluetoothMode)
UserDefaults.standard.set(micMode.rawValue, forKey: kMicMode)
UserDefaults.standard.set(captureChannels.rawValue, forKey: kCaptureChannels)
UserDefaults.standard.set(selectedInputPortId, forKey: kInputPortId)
UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId)
UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern)
}
// MARK: - Selection setters (called from SettingsView pickers)
func selectInputPort(_ portId: String) {
selectedInputPortId = portId
selectedDataSourceId = nil
selectedPolarPattern = nil
savePreferences()
applyConfiguration()
refreshRoutes()
}
func selectDataSource(_ dataSourceId: String) {
selectedDataSourceId = dataSourceId
selectedPolarPattern = nil
savePreferences()
applyConfiguration()
refreshRoutes()
}
func selectPolarPattern(_ pattern: String) {
selectedPolarPattern = pattern
savePreferences()
applyConfiguration()
refreshRoutes()
}
func selectBluetoothMode(_ mode: BluetoothMode) {
bluetoothMode = mode
savePreferences()
applyConfiguration()
refreshRoutes()
}
func selectMicMode(_ mode: MicMode) {
micMode = mode
savePreferences()
applyConfiguration()
updateWarnings()
}
func selectCaptureChannels(_ channels: CaptureChannels) {
captureChannels = channels
savePreferences()
applyConfiguration()
// Update the core's stored capture channel count (does not restart the engine).
if let streamId = AudioSessionManager.shared.activeMicStreamId {
_ = AudioSessionManager.shared.client?.setCaptureChannels(
streamId: streamId, channels: channels.channelCount)
}
// Restart the engine AFTER AVAudioSession routing has settled and the channel
// count is stored. The engine reopens playback first (committing the A2DP/output
// route), then capture avoiding the race where stereo capture activation drops
// A2DP before the playback device has a chance to claim the route.
_ = AudioSessionManager.shared.client?.audioRestart()
}
// MARK: - Presets
/// Apply a preset sets all individual audio settings to the preset's values, then
/// applies the configuration. For presets that use the built-in mic (A2DP presets),
/// finds the built-in mic port UID from availableInputs.
func applyPreset(_ preset: AudioPreset) {
guard preset != .custom else { return } // can't "apply" custom it's a display state
bluetoothMode = preset.bluetoothMode
micMode = preset.micMode
captureChannels = preset.captureChannels
if preset.usesBuiltInMic {
// Find the built-in mic port from available inputs and select it.
let session = AVAudioSession.sharedInstance()
if let builtInMic = (session.availableInputs ?? []).first(where: {
$0.portType == .builtInMic
}) {
selectedInputPortId = builtInMic.uid
}
// Don't set a specific data source in stereo mode, iOS uses multiple mic
// capsules automatically. In mono, the default orientation is fine.
selectedDataSourceId = nil
selectedPolarPattern = nil
} else {
// For Default and Bluetooth Headset presets, let the system pick the input.
selectedInputPortId = nil
selectedDataSourceId = nil
selectedPolarPattern = nil
}
UserDefaults.standard.set(preset.rawValue, forKey: kPreset)
savePreferences()
applyConfiguration()
// Update the core's stored capture channel count (does not restart the engine).
if let streamId = AudioSessionManager.shared.activeMicStreamId {
_ = AudioSessionManager.shared.client?.setCaptureChannels(
streamId: streamId, channels: preset.captureChannels.channelCount)
}
// Restart the engine AFTER AVAudioSession routing has settled and the channel
// count is stored. Playback opens first (commits A2DP route), then capture.
_ = AudioSessionManager.shared.client?.audioRestart()
refreshRoutes()
logger.info("applyPreset — \(preset.rawValue)")
}
// MARK: - Helpers
/// Update warning indicators for the Settings UI.
private func updateWarnings() {
let session = AVAudioSession.sharedInstance()
let outputIsSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
// Raw/Studio mode + speaker = echo risk (no AEC in .measurement mode)
showsRawModeSpeakerWarning = (micMode == .raw && outputIsSpeaker)
// A2DP output runs without hardware AEC (the .voiceChat AEC path isn't available on an
// A2DP route). Applies to both mono and stereo A2DP. Stereo also has no AEC (it can't
// use .voiceChat at all), but the message is the same and the warning already shows when
// the bluetooth mode is A2DP.
showsA2dpNoAecWarning = (bluetoothMode == .builtInMicBtA2dp)
}
/// Whether the current configuration wants Bluetooth A2DP output. Used after session
/// activation to clear any lingering speaker override that would pin output to the speaker.
var wantsA2dpOutput: Bool { bluetoothMode == .builtInMicBtA2dp }
/// The selected input port object, if any.
var selectedPort: IOSAudioInputPort? {
inputPorts.first(where: { $0.id == selectedInputPortId })
}
/// The data sources of the selected input port, if it's the built-in mic.
var selectedPortDataSources: [IOSAudioDataSource]? {
selectedPort?.dataSources
}
/// Whether the selected input port is the built-in mic (has data sources / orientation).
var selectedPortIsBuiltInMic: Bool {
selectedPort?.portType == AVAudioSession.Port.builtInMic.rawValue
}
}
-47
View File
@@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>VoiceCat</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.1</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 VoiceCat contributors. All rights reserved.</string>
<key>NSMicrophoneUsageDescription</key>
<string>VoiceCat needs microphone access to transmit your voice in channels.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiresFullScreen</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

Some files were not shown because too many files have changed in this diff Show More