45 Commits
Author SHA1 Message Date
Talon f3ac779bf4 Survive changing networks and deepen the receive buffer
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
Media died silently whenever a client's source address changed. The relay bound a
peer's endpoint once and refused to move it, and the client stopped offering its
binding token after the first bind, so a Wi-Fi/cellular handover stranded the
session in both directions. Add an authenticated Rebind media frame: the binding
token travels in the clear for peer lookup only, and the AEAD tag over header and
token plus the peer's existing replay window are what authorize the move, so a
captured rebind cannot be replayed to redirect someone else's downlink. The client
rebuilds its UDP socket instead of retrying on one still pinned to a vanished
interface.

Nothing judged the control connection live: pings were sent and pongs ignored, so a
blackholed TCP path went unnoticed for minutes while the UI showed a live session.
Treat any server traffic as liveness and fail the connection when it stops, which
drives the existing reconnect.

The receive jitter buffer had lost its depth floor, so a channel without FEC or
DRED played out with no buffer at all and ordinary reordering became concealment.
Restore a one-frame floor, observe every arrival rather than only accepted ones —
a shallow buffer was rejecting the late arrivals that should have deepened it —
and allow playout to hold a frame so depth can follow a degrading link. A stalled
consumer now sheds the oldest queued packet instead of refusing the live talkspurt.

Add a deterministic network-impairment simulation covering bursty loss, jitter,
reordering, duplication, outages and a stalled consumer, a handover test against a
real relay, a replay test for the rebind path, and a blackholed control connection
driven through a freezable TCP proxy.
2026-09-24 19:15:16 +02:00
Talon fb740bcfb2 Try to fix background glitching over long periods of time
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
2026-09-24 13:31:16 +02:00
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
472 changed files with 16879 additions and 40494 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
+3 -1
View File
@@ -3,6 +3,9 @@
# Previous build outputs
build/
artifacts/
**/bin/
**/obj/
# Native GUI client code (Swift/Xcode, C#/WinForms) — server build doesn't need these
clients/
@@ -15,7 +18,6 @@ PROGRESS.md
CLAUDE.md
# Editor / tooling config
.clang-format
.vscode/
.idea/
+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
-87
View File
@@ -1,87 +0,0 @@
name: Build Linux Binaries
# Builds stripped voicecat-server + voicecat-admin for linux/amd64 and linux/arm64.
# Run manually from the Actions tab, or on any push to main.
# Artifacts are downloadable from the workflow run for ~90 days.
on:
workflow_dispatch: # manual trigger from the Actions tab
push:
branches: [main]
paths:
- 'core/**'
- 'server/**'
- 'tools/**'
- 'cmake/**'
- 'CMakeLists.txt'
- 'CMakePresets.json'
- 'vcpkg.json'
- 'Dockerfile'
- '.github/workflows/build-linux.yml'
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
vcpkg_triplet: x64-linux
- arch: arm64
runner: ubuntu-24.04-arm
vcpkg_triplet: arm64-linux
name: linux/${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- name: Cache vcpkg packages
uses: actions/cache@v4
with:
path: |
~/.cache/vcpkg
/usr/local/share/vcpkg/buildtrees
key: vcpkg-${{ matrix.vcpkg_triplet }}-${{ hashFiles('vcpkg.json') }}
restore-keys: |
vcpkg-${{ matrix.vcpkg_triplet }}-
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential cmake ninja-build git curl zip unzip tar \
pkg-config autoconf autoconf-archive automake libtool nasm python3
- name: Set up vcpkg
run: |
VCPKG_COMMIT=$(jq -r '."builtin-baseline"' vcpkg.json)
git init /tmp/vcpkg
git -C /tmp/vcpkg remote add origin https://github.com/microsoft/vcpkg.git
git -C /tmp/vcpkg fetch --depth=1 origin "$VCPKG_COMMIT"
git -C /tmp/vcpkg checkout FETCH_HEAD
/tmp/vcpkg/bootstrap-vcpkg.sh -disableMetrics
echo "VCPKG_ROOT=/tmp/vcpkg" >> "$GITHUB_ENV"
echo "VCPKG_DISABLE_METRICS=1" >> "$GITHUB_ENV"
- name: Build (server-release preset)
run: |
cmake --preset server-release
cmake --build --preset server-release
- name: Collect binaries
run: |
mkdir -p dist
cp build/server-release/bin/voicecat-server dist/
cp build/server-release/bin/voicecat-admin dist/
file dist/voicecat-server dist/voicecat-admin
ls -lh dist/
- name: Upload binaries
uses: actions/upload-artifact@v4
with:
name: voicecat-linux-${{ matrix.arch }}
path: dist/
retention-days: 90
+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
+6 -6
View File
@@ -1,4 +1,8 @@
# Build output
**/bin/
**/obj/
**/TestResults/
/artifacts/
/build/
/out/
@@ -15,7 +19,6 @@
*.pdb
# vcpkg (bundled as a submodule at /vcpkg — see docs/building.md §2)
/vcpkg_installed/
# Generated protobuf
*.pb.cc
@@ -37,19 +40,16 @@ Thumbs.db
# Apple / Windows client build artifacts
clients/apple/**/build/
clients/apple/**/bin/
clients/apple/**/obj/
clients/apple/**/*.xcodeproj/xcuserdata/
clients/apple/**/*.xcodeproj/project.xcworkspace/
clients/apple/**/*.xcframework/
clients/windows/**/bin/
clients/windows/**/obj/
# 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
# (core/src/core/client.cpp falls back to this relative path when tofu_store_path is unset).
voicecat_tofu_pins.txt
# Python bytecode cache (e.g. scripts/asc_api.py)
__pycache__/
-3
View File
@@ -1,3 +0,0 @@
[submodule "vcpkg"]
path = vcpkg
url = https://github.com/microsoft/vcpkg.git
+38 -101
View File
@@ -1,115 +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
files:
Start with `CLAUDE.md` for commands and architecture and `PROGRESS.md` for the current handoff.
The supported product is the .NET 10 implementation.
- [`CLAUDE.md`](CLAUDE.md) — the hub: build/test commands, architecture at a glance, doc map.
- [`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.
## Definition of done
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
control plane, encrypted UDP voice (Opus), multi-stream, TOFU identity pinning, channel tree,
permissions, moderation, disconnect/keepalive/reaper. The Windows WinForms C# client is
shipped (M4). The macOS/iOS Swift client is next. The `skeleton` preset still links a
no-deps stub path (`VC_ERR_NOT_IMPLEMENTED`) for smoke-check builds.
1. Read the relevant code and current contract document.
2. Add or adjust the smallest behavior test that demonstrates the requested result.
3. Implement the change without weakening encryption, real-time safety, or accessibility.
4. Build and run the affected tests, then the full managed test suite when practical.
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
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:
| Path | Purpose |
|---|---|
| `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".
- 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). vcpkg is
bundled as a git submodule at `vcpkg/`, pinned to `vcpkg.json`'s `builtin-baseline`:
## Core verification
```bash
git submodule update --init vcpkg # one-time, after cloning
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
cmake --preset dev
cmake --build --preset dev
ctest --preset dev
./scripts/build-native.ps1
dotnet restore VoiceCat.slnx --locked-mode
dotnet build VoiceCat.slnx -c Release --no-restore
dotnet test VoiceCat.slnx -c Release --no-build
./scripts/check-licenses.ps1
```
To use an external vcpkg checkout instead, `export VCPKG_ROOT=/path/to/vcpkg` — it always
takes priority over the bundled submodule.
Apple builds additionally use `./scripts/build-native-ios.sh` and
`clients/apple/VoiceCat.Apple.slnx` on macOS.
Skeleton (no third-party deps — works immediately, no vcpkg needed):
## Hard rules
```bash
cmake --preset skeleton
cmake --build --preset skeleton
ctest --preset skeleton
```
> **Windows gotcha — always run `ctest` and built binaries via PowerShell, not Git Bash.**
> MinGW-built executables fail in Git Bash with exit code `0xc0000139`
> (STATUS_ENTRYPOINT_NOT_FOUND) even though the file exists and is marked executable.
> PowerShell runs them correctly. Use the PowerShell tool (not Bash) for any `ctest`,
> `voicecat-server.exe`, or `vccli.exe` invocation on Windows.
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), which also resolves
`VCPKG_ROOT` (env var override, else the bundled `vcpkg/` submodule).
## 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).
- Encryption is mandatory: TLS control plus authenticated encrypted UDP media.
- Never introduce GPL/LGPL dependencies.
- Real-time audio callbacks never allocate, lock, block, or perform I/O.
- Preserve accessible names, keyboard operation, and curated screen-reader announcements.
- Wire, database, and shared-ring changes are deliberate versioned changes.
- Keep `PROGRESS.md` short. Use Git history rather than accumulating completed-work prose.
+35 -160
View File
@@ -1,174 +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
where everything is. For the *working method* read [`AGENTS.md`](AGENTS.md); for *what's done
and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](docs/).
VoiceCat is a self-hosted encrypted voice and text chat system. The supported implementation
is .NET 10. Read `AGENTS.md` for working rules and `PROGRESS.md` for the current short status.
> **One-line status:** M5 (moderation & admin UI) is complete — permissions, kick/ban/move,
> 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 — 29/29 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`).**
> **Noise suppression shipped (RNNoise, vendored at `third_party/rnnoise/`)** — both send-side
> mic NR (`vc_set_input_noise_reduction`) and per-listener receive NR; client on/off toggles ship
on all three clients (receive NR now denoises stereo mic streams too — fixed 2026-06-23).
> 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.
## Build and test
```bash
# Configure + build (default development preset; needs VCPKG_ROOT)
cmake --preset dev
cmake --build --preset dev
# Run the tests (21 behavior tests — grows per milestone)
# 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
./scripts/build-native.ps1
dotnet restore VoiceCat.slnx --locked-mode
dotnet build VoiceCat.slnx -c Release --no-restore
dotnet test VoiceCat.slnx -c Release --no-build
./scripts/check-licenses.ps1
```
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
cmake --preset skeleton # no-deps stub smoke (no VCPKG_ROOT needed) — 2 tests
cmake --preset release # optimized + tests on, symbols kept (profile/debug-friendly)
cmake --preset server-release # optimized + stripped, no tests (deployment-shaped)
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)
./scripts/build-native-ios.sh
dotnet restore clients/apple/VoiceCat.Apple.slnx
dotnet build clients/apple/VoiceCat.Apple.slnx -c Debug --no-restore
```
Vcpkg triplet is auto-resolved from the host platform by
[`cmake/voicecat-toolchain.cmake`](cmake/voicecat-toolchain.cmake) — `x64-mingw-static` on
Windows, `x64-linux` on Linux, `arm64-osx` on Apple Silicon. See docs/building.md §1
"Platform matrix" for details.
Windows publishing uses `clients/windows/publish-client.ps1`. Server publishing uses
`scripts/publish-server.ps1`.
vcpkg is bundled as a git submodule at `vcpkg/`, pinned to the commit in `vcpkg.json`'s
`builtin-baseline`. One-time setup after cloning:
## Current architecture
```bash
git submodule update --init vcpkg
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
```
- `proto/voicecat.proto` is the control-plane wire schema.
- `src/VoiceCat.Protocol` owns protobuf framing and generated types.
- `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.
To use an external vcpkg checkout instead (e.g. one shared across projects), set
`VCPKG_ROOT` — it always takes priority over the bundled submodule:
## Invariants
```bash
export VCPKG_ROOT=/path/to/vcpkg # works on Linux / macOS / Windows
```
Other useful toggles (pass with `-D` at configure time):
```bash
cmake --preset dev -DVOICECAT_BUILD_SHARED=ON # build libvoicecat as a .dll/.so/.dylib (for the C# client)
cmake --preset dev -DVOICECAT_BUILD_SERVER=OFF # core + tools only
cmake --preset dev -DVOICECAT_BUILD_TESTS=OFF
```
Formatting: `clang-format` config is `.clang-format` (Google base, 100 cols, 4-space).
```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`.
- TLS control and encrypted UDP media are mandatory; do not add plaintext transports.
- No GPL or LGPL dependencies.
- Audio callbacks must not allocate, lock, block, or perform network I/O.
- 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.
- Keep `PROGRESS.md` concise; do not append a historical changelog.
-67
View File
@@ -1,67 +0,0 @@
cmake_minimum_required(VERSION 3.25)
project(voicecat
VERSION 0.0.1
DESCRIPTION "Self-hosted native voice & text chat (see docs/)"
# C is needed for the vendored RNNoise noise-suppression lib (third_party/rnnoise).
LANGUAGES CXX C)
# 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 ───────────────────────────────────────────────────────────────────
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)
# ── Static MinGW runtime (Windows) ────────────────────────────────────────────
# x64-mingw-static only statically links vcpkg's OWN deps (protobuf, sodium, ...);
# the GCC/MinGW runtime stays dynamic by default, so every produced .exe/.dll
# otherwise depends on libgcc_s_seh-1.dll / libwinpthread-1.dll / libstdc++-6.dll
# at runtime — DLLs that exist on a dev box (MSYS2/UCRT64) but not on a clean
# Windows machine, where the server then fails to start with "… was not found".
# Apply the fully-static-MinGW recipe to ALL targets so binaries are portable.
# (The SHARED voicecat.dll already sets these in core/CMakeLists.txt; the duplicate
# is harmless. Verify with: objdump -p build/<preset>/bin/voicecat-server.exe |
# grep "DLL Name" — only Windows system DLLs should remain.)
if(WIN32 AND MINGW)
add_link_options(-static-libgcc -static-libstdc++ -static -lwinpthread)
endif()
# ── Targets ───────────────────────────────────────────────────────────────────
add_subdirectory(core)
if(VOICECAT_BUILD_SERVER)
add_subdirectory(server)
endif()
if(VOICECAT_BUILD_TOOLS)
add_subdirectory(tools/vccli)
add_subdirectory(tools/voicecat-admin)
endif()
if(VOICECAT_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
message(STATUS "VoiceCat ${PROJECT_VERSION} configured "
"(server: ${VOICECAT_BUILD_SERVER}, tools: ${VOICECAT_BUILD_TOOLS})")
-134
View File
@@ -1,134 +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. Resolves vcpkg from the bundled git submodule (vcpkg/) unless VCPKG_ROOT points at an external checkout.",
"generator": "Ninja",
"toolchainFile": "${sourceDir}/cmake/voicecat-toolchain.cmake",
"cacheVariables": {
"VOICECAT_USE_VCPKG_DEPS": "ON"
}
},
{
"name": "dev",
"displayName": "Dev (full real-deps build, vcpkg)",
"description": "Day-to-day development preset. Real protocol, crypto, voice, server. Builds server + tools + tests. Auto-triplet: x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon. Requires vcpkg bootstrapped (bundled submodule or 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 bootstrapped (bundled submodule or 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 bootstrapped (bundled submodule or 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)",
"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 bootstrapped (bundled submodule or 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 bootstrapped (bundled submodule or 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 bootstrapped (bundled submodule or 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": "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": "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>
+20 -91
View File
@@ -1,98 +1,27 @@
# syntax=docker/dockerfile:1
# ─────────────────────────────────────────────────────────────────────────────
# Stage 1 — Build
# ─────────────────────────────────────────────────────────────────────────────
FROM ubuntu:24.04 AS builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
cmake \
ninja-build \
git \
curl \
zip \
unzip \
tar \
pkg-config \
ca-certificates \
autoconf \
autoconf-archive \
automake \
libtool \
nasm \
python3 \
&& rm -rf /var/lib/apt/lists/*
# Fetch vcpkg at the exact commit pinned in vcpkg.json builtin-baseline.
# vcpkg resolves baselines via `git show <sha>:versions/baseline.json`, so it
# needs a real .git repo — not a tarball. A single shallow fetch is fast (~30 MB)
# and gives vcpkg exactly what it needs.
ARG VCPKG_COMMIT=d46283cf33cf5de7bd88e12156ce03882be1f179
RUN git init /vcpkg \
&& git -C /vcpkg remote add origin https://github.com/microsoft/vcpkg.git \
&& git -C /vcpkg fetch --depth=1 origin "${VCPKG_COMMIT}" \
&& git -C /vcpkg checkout FETCH_HEAD \
&& /vcpkg/bootstrap-vcpkg.sh -disableMetrics
ENV VCPKG_ROOT=/vcpkg
ENV VCPKG_DISABLE_METRICS=1
# syntax=docker/dockerfile:1.7
FROM mcr.microsoft.com/dotnet/sdk:10.0.203-noble AS build
WORKDIR /src
COPY . .
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
ARG TARGETARCH
# Three cache mounts:
# downloads — source tarballs (~200 MB); safe to share across arches
# vcpkg-cache — vcpkg binary cache (pre-built .zip archives per package ABI);
# restores packages in seconds on subsequent builds instead of
# recompiling. Scoped by arch so amd64/arm64 don't collide.
# buildtrees — NOT cached; deleted at end of layer so neither the Docker
# image nor the BuildKit cache accumulates several GB of
# intermediate build artifacts.
ENV VCPKG_BINARY_SOURCES="clear;files,/vcpkg-cache,readwrite"
RUN --mount=type=cache,target=/vcpkg/downloads \
--mount=type=cache,target=/vcpkg-cache,id=vc-bin-${TARGETARCH} \
cmake --preset server-release \
&& cmake --build --preset server-release \
&& rm -rf /vcpkg/buildtrees
# ─────────────────────────────────────────────────────────────────────────────
# Stage 2 — Export (binary-only, used by scripts/build-linux-binaries.sh)
# docker buildx build --target export --output type=local,dest=./dist/linux-amd64 .
# ─────────────────────────────────────────────────────────────────────────────
FROM scratch AS export
COPY --from=builder /src/build/server-release/bin/voicecat-server /voicecat-server
COPY --from=builder /src/build/server-release/bin/voicecat-admin /voicecat-admin
COPY --from=build /out/VoiceCat.Server /VoiceCat.Server
# ─────────────────────────────────────────────────────────────────────────────
# Stage 3 — Runtime (default stage — must be last)
# ─────────────────────────────────────────────────────────────────────────────
FROM ubuntu:24.04 AS runtime
ENV DEBIAN_FRONTEND=noninteractive
# ca-certificates is useful if the server ever makes outbound TLS calls; also
# satisfies any mbedTLS system-CA lookup at runtime.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -r voicecat && useradd -r -g voicecat -s /sbin/nologin voicecat
COPY --from=builder /src/build/server-release/bin/voicecat-server /usr/local/bin/voicecat-server
COPY --from=builder /src/build/server-release/bin/voicecat-admin /usr/local/bin/voicecat-admin
RUN mkdir -p /data && chown voicecat:voicecat /data
USER voicecat
# Persistent state: Ed25519 identity key, self-signed TLS cert, SQLite database.
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"]
# Control (TLS 1.3) and media (ChaCha20-Poly1305) share one port number on TCP+UDP.
EXPOSE 8384/tcp
EXPOSE 8384/udp
ENTRYPOINT ["/usr/local/bin/voicecat-server"]
CMD ["--data-dir", "/data"]
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"]
+69 -1214
View File
File diff suppressed because it is too large Load Diff
+27 -53
View File
@@ -1,68 +1,42 @@
# VoiceCat
Self-hosted, native voice & text chat in the spirit of classic TeamSpeak / Mumble —
channel-based voice, channel + private text, one server you run yourself. Plain **TCP**
(control) and **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.
VoiceCat is a self-hosted, channel-based voice and text chat system built on .NET 10. It uses
TLS 1.3 for protobuf control traffic and authenticated encrypted UDP for Opus media. There is
no WebRTC, central directory, or plaintext mode.
> **Status:** Design complete in [`docs/`](docs/). M1M5 are implemented — real TLS control
> plane, encrypted UDP voice (Opus), multi-stream, TOFU identity pinning, channel tree,
> permissions, moderation, disconnect/keepalive/reaper. Windows WinForms C# client shipped
> (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`.
The repository contains a managed server, CLI, shared client/audio core, and native Windows,
macOS, and iOS user interfaces. A small C library supplies Opus/RNNoise, and a small Swift iOS
extension captures ReplayKit application audio.
## 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
# one-time vcpkg setup (bundled as a submodule, pinned to vcpkg.json's builtin-baseline):
git submodule update --init vcpkg
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
# configure + build + test:
cmake --preset dev
cmake --build --preset dev
ctest --preset dev # 21 behavior tests
./scripts/build-native.ps1
dotnet restore VoiceCat.slnx --locked-mode
dotnet build VoiceCat.slnx -c Release --no-restore
dotnet test VoiceCat.slnx -c Release --no-build
```
To use an external vcpkg checkout instead, set `VCPKG_ROOT=/path/to/vcpkg` (or
`$env:VCPKG_ROOT` on PowerShell) — it always takes priority over the bundled submodule.
Artifacts land in `build/dev/bin/` (`voicecat-server`, `vccli`, `voicecat-admin`).
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).
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.
## Layout
```
docs/ design spec (read this)
core/ libvoicecat — the shared C++ core
include/ voicecat.h (the C ABI all clients call)
proto/ voicecat.proto (control-plane wire format, source of truth)
src/ net/ crypto/ codec/ protocol/ session/ audio/ (stubs today)
server/ voicecat-server (headless; links the core)
tools/vccli/ headless test client — drives the protocol from M1 on
clients/ apple/ (Swift, M4) windows/ (C#, M4) — placeholders for now
tests/ CTest targets
```text
proto/ protobuf wire schema
src/ managed protocol, crypto, server, client, audio, and CLI
tests/ managed behavior and integration tests
clients/windows/ WinForms client
clients/apple/ AppKit and UIKit clients
native/media/ narrow Opus/RNNoise C shim
native/rnnoise/ vendored RNNoise source and model
native/apple/broadcast/ ReplayKit broadcast extension
docs/ current contracts and operating documentation
```
## License
## Non-negotiable constraints
Permissive-only dependencies (no GPL/LGPL) so the project can be redistributed freely,
including closed-source. Project license: TBD (see [`docs/tech-stack.md`](docs/tech-stack.md) §5).
- Encryption is mandatory.
- 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>
-68
View File
@@ -1,68 +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",
// Event-cue WAVs (shared with the Windows client) bundled into the package's
// resource bundle; EventFeedback loads them via Bundle.module. Copied from
// assets/sounds/ into Sources/VoiceCatCore/Sounds/.
resources: [.process("Sounds")]
),
// 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 -150
View File
@@ -1,160 +1,45 @@
# Apple client (macOS + iOS)
# Apple clients
Built in **M4** (see [`docs/roadmap.md`](../../docs/roadmap.md)). One shared **Swift core**
(`VoiceCatCore` package) wrapping the C ABI ([`core/include/voicecat.h`](../../core/include/voicecat.h)),
with platform-specific UIs: **AppKit** for macOS (best VoiceOver accessibility), **SwiftUI**
for iOS. See [`docs/architecture.md`](../../docs/architecture.md) §4 and
[`docs/tech-stack.md`](../../docs/tech-stack.md) §2.
`VoiceCat.Mac` and `VoiceCat.iOS` are .NET 10 AppKit and UIKit clients over the shared managed
core. The ReplayKit upload extension under `native/apple/broadcast` remains Swift because it
runs under the extension memory limit and writes the versioned shared audio ring.
## What's here now
### `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`:
Build on macOS with Xcode and the pinned .NET workloads:
```bash
# Prerequisites: VCPKG_ROOT set, Xcode installed
export VCPKG_ROOT=/path/to/vcpkg
# Build the macOS slice + fat static lib + XCFramework (validated)
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
./scripts/build-native.ps1
./scripts/build-native-ios.sh
dotnet restore clients/apple/VoiceCat.Apple.slnx
dotnet build clients/apple/VoiceCat.Apple.slnx -c Debug --no-restore
```
### 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
own object files — vcpkg's static dependencies (protobuf, mbedtls, libsodium, opus, sqlite3,
spdlog, asio, abseil, …) are 107 separate `.a` files under `vcpkg_installed/arm64-osx/lib/`,
and the vendored RNNoise noise-suppression lib (`third_party/rnnoise/`, built as a CMake
target → `build/<preset>/lib/librnnoise.a`) is another. A Swift Package binary target can
only link ONE `.a` per XCFramework slice, so `build-xcframework.sh` merges them all — vcpkg
deps plus the locally-built vendored libs — into a single self-contained `libvoicecat-fat.a`
(~33 MB) using `libtool -static`. This is the Apple equivalent of how the Windows client
ships a single `voicecat.dll` with all deps statically linked (via MinGW's `-static` flags
in [`core/CMakeLists.txt`](../../core/CMakeLists.txt)). If you add another vendored (non-vcpkg)
static-lib target to the core, it's picked up automatically as long as it lands in
`build/<preset>/lib/` and isn't named `libvoicecat*`.
For a physical iOS device, use `build-ios-device.sh` and `deploy-ios-device.sh`. The host and
ReplayKit extension require signing profiles with App Group `group.me.iamtalon.voicecat`.
Hardware validation must cover VoiceOver, background and lock behavior, interruptions, route
changes, Bluetooth, ReplayKit, and iOS 27 ScreenCaptureKit audio.
For iOS voice stability, leave a call joined with the microphone active for at least 30 minutes
and confirm speech stays clear and `VC_AUDIO` reports no growing `feedDrops`. While still joined,
toggle Wi-Fi off and on, switch between Wi-Fi and cellular, and confirm the app stays open,
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
swift build # builds VoiceCatCore library
swift test # runs 6 smoke tests against a real voicecat-server
```
App Store builds use the same device builder with `--configuration Release`. Set
`VOICECAT_BUILD_NUMBER`, `VOICECAT_DISPLAY_VERSION`, `VOICECAT_DEVELOPMENT_TEAM`, the host
`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:
- A **binary target** (`VoiceCatCoreXCF`) pointing at the local `VoiceCatCore.xcframework`.
- 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.
## Ad-hoc distribution (iOS, pre-TestFlight)
To hand the iOS app to a handful of friends before TestFlight, use
[`scripts/dist-ios-adhoc.sh`](../../scripts/dist-ios-adhoc.sh). It registers each device's
UDID, builds an ad-hoc-signed `VoiceCatiOS.ipa`, and generates the `manifest.plist` +
`index.html` for an over-the-air (`itms-services://`) web install. Ad-hoc builds only run on
devices whose UDID is registered *before* signing, and stock iOS won't install a bare `.ipa`
without a sideloading tool — so the web-install page is the friend-friendly path.
```bash
# One-time: create an App Store Connect API "Team Key" (.p8, Admin/App Manager access) at
# App Store Connect → Users and Access → Integrations → App Store Connect API
export ASC_KEY_ID=ABC123 ASC_ISSUER_ID=1111-... ASC_KEY_PATH=~/.appstoreconnect/AuthKey_ABC123.p8
# Register a device + build + stage everything into dist/ios-adhoc/
scripts/dist-ios-adhoc.sh --udid <UDID> --name "Friend iPhone" \
--base-url https://example.com/voicecat
```
Then upload the three staged files (`VoiceCatiOS.ipa`, `manifest.plist`, `index.html`) to
that **HTTPS** folder and open `index.html` in Safari on a registered iPhone (iOS 18+).
Device UDID registration is automated via [`scripts/asc_api.py`](../../scripts/asc_api.py)
(`asc_api.py list` shows the registered devices against the 100-iOS-devices/year cap).
Requires a paid Apple Developer Program membership. Run `scripts/dist-ios-adhoc.sh --help`
for all flags.
The complete TestFlight workflow is documented in `docs/apple-ios-release.md`. The shared ring
contract is documented in `docs/broadcast-ring-format.md`.
@@ -1,37 +0,0 @@
// C callbacks use an unretained `user` context. Client destruction joins callback threads,
// and transient event pointers are copied before the callback returns.
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,157 +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
/// 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
/// Reply to `joinChannel()` see `VoiceCatEvent.result` / `.channelId`.
case joinResult = 12
/// The TOFU server-identity gate see `VoiceCatEvent.tofuStatus` / `.text`.
case serverIdentity = 13
/// Async result for moderation/admin/channel operations.
case genericResult = 14
/// Reply to `requestAccountList()` call `listAccounts()` to read.
case accountList = 15
/// Voice-plane subscription state. `u32a` = 1 (subscribed) or 0 (unsubscribed).
case voiceState = 16
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,105 +0,0 @@
// EventFeedback shared audible + spoken feedback for session events, used by both the macOS
// (AppKit) and iOS (SwiftUI) clients. Mirrors the Windows client's EventFeedback policy
// (clients/windows/.../Notifications/EventFeedback.cs): the platform event handlers decide WHAT
// to play (they own the model/nickname/channel context); this type owns the "should I, and how"
// policy plus the AVFoundation playback/synthesis.
//
// Sound effects use AVAudioPlayer; spoken announcements use the OS-native AVSpeechSynthesizer.
//
// NOTE (iOS): on the voice path the app runs a play-and-record AVAudioSession (VPIO). Playing
// these cues / speech over that session can interact with the live call (ducking, route, or the
// mute switch). The session category should allow mixing verify on device. This is the most
// likely place for platform bugs to surface.
import Foundation
import AVFoundation
/// User preferences for event sounds and spoken feedback, backed by UserDefaults so the macOS
/// and iOS settings screens and this player share one source of truth.
public struct FeedbackSettings: Sendable {
public var sounds: Bool
public var speech: Bool
public var volume: Float
public var selfTalkSounds: Bool
public var pttSound: Bool
static let keySounds = "feedback.sounds"
static let keySpeech = "feedback.speech"
static let keyVolume = "feedback.volume"
static let keySelfTalk = "feedback.selfTalk"
static let keyPtt = "feedback.ptt"
/// Default values registered with UserDefaults (so "unset" reads as the intended default
/// rather than false/0).
static let defaults: [String: Any] = [
keySounds: true,
keySpeech: false,
keyVolume: 1.0,
keySelfTalk: false,
keyPtt: false,
]
/// The current settings, read live from UserDefaults.
public static var current: FeedbackSettings {
let d = UserDefaults.standard
d.register(defaults: defaults) // idempotent ensures "unset" reads as the intended default
return FeedbackSettings(
sounds: d.bool(forKey: keySounds),
speech: d.bool(forKey: keySpeech),
volume: d.float(forKey: keyVolume),
selfTalkSounds: d.bool(forKey: keySelfTalk),
pttSound: d.bool(forKey: keyPtt))
}
}
@MainActor
public final class EventFeedback {
public static let shared = EventFeedback()
private var players: [SoundEvent: AVAudioPlayer] = [:]
private let synthesizer = AVSpeechSynthesizer()
private init() {
UserDefaults.standard.register(defaults: FeedbackSettings.defaults)
}
// MARK: - Sounds
/// Play an event cue, honouring the user's settings. The two opt-in categories (your own
/// voice-activity, and the PTT cue) are gated by their own flags.
public func play(_ event: SoundEvent) {
let s = FeedbackSettings.current
guard s.sounds, s.volume > 0 else { return }
if (event == .vaStart || event == .vaStop), !s.selfTalkSounds { return }
if event == .ptt, !s.pttSound { return }
guard let player = player(for: event) else { return }
player.volume = s.volume
player.currentTime = 0
player.play()
}
/// Lazily load and cache an AVAudioPlayer for the event's bundled WAV. Returns nil (silent)
/// if the resource is missing or fails to load.
private func player(for event: SoundEvent) -> AVAudioPlayer? {
if let cached = players[event] { return cached }
guard let url = Bundle.module.url(forResource: event.resourceName, withExtension: "wav"),
let player = try? AVAudioPlayer(contentsOf: url) else {
return nil
}
player.prepareToPlay()
players[event] = player
return player
}
// MARK: - Speech
/// Speak `text` when spoken feedback is enabled. Utterances queue (do not interrupt prior
/// speech) so a burst of events is read in order.
public func speak(_ text: String) {
guard FeedbackSettings.current.speech else { return }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
synthesizer.speak(AVSpeechUtterance(string: trimmed))
}
}
@@ -1,43 +0,0 @@
// SoundEvent the cross-platform set of audible event cues. Each maps to a WAV bundled as an
// SPM resource (Sources/VoiceCatCore/Sounds/, copied from assets/sounds/). The same logical set
// is mirrored in the Windows client (clients/windows/.../Notifications/SoundEvent.cs) so feedback
// stays consistent across platforms.
import Foundation
public enum SoundEvent: CaseIterable, Sendable {
case channelJoin // another user joined my channel
case channelLeave // another user left my channel
case channelRecv // channel text message from someone else
case channelSent // channel text message I sent
case pmRecv // private message received
case pmSent // private message I sent
case login // connected / authenticated
case logout // clean disconnect
case connectionLost // unexpected disconnect
case voiceOn // my microphone stream started
case voiceOff // my microphone stream stopped
case vaStart // my voice-activity began (off by default)
case vaStop // my voice-activity ended (off by default)
case ptt // push-to-talk engaged (off by default)
/// Resource name (without extension) as bundled in Sources/VoiceCatCore/Sounds/.
var resourceName: String {
switch self {
case .channelJoin: return "channel_join"
case .channelLeave: return "channel_leave"
case .channelRecv: return "channel_recv"
case .channelSent: return "channel_sent"
case .pmRecv: return "pm_recv"
case .pmSent: return "pm_sent"
case .login: return "login"
case .logout: return "logout"
case .connectionLost: return "connection_lost"
case .voiceOn: return "voice_on"
case .voiceOff: return "voice_off"
case .vaStart: return "va_start"
case .vaStop: return "va_stop"
case .ptt: return "ptt"
}
}
}
@@ -1,109 +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, sortOrder: c.sort_order,
audio: audioConfig(c.audio)))
}
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,
voiceSubscribed: u.voice_subscribed != 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, dred: c.dred != 0)
}
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,250 +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 let sortOrder: UInt32
/// Authoritative channel Opus params (docs/voice.md §3). Populated from the Channel proto
/// so the edit dialog can read back the current config.
public let audio: AudioConfig
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
passwordProtected: Bool, maxUsers: UInt32, sortOrder: UInt32,
audio: AudioConfig) {
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
self.passwordProtected = passwordProtected; self.maxUsers = maxUsers
self.sortOrder = sortOrder; self.audio = audio
}
}
/// 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 let voiceSubscribed: Bool
public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32,
selfMicMuted: Bool, selfDeafened: Bool, serverMuted: Bool,
serverDeafened: Bool, voiceSubscribed: 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
self.voiceSubscribed = voiceSubscribed
}
}
/// Permission bitset mirrors `vc_permissions`.
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` (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 let dred: Bool // Deep REDundancy (Opus 1.6), off by default
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, dred: Bool = false) {
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; self.dred = dred
}
}
/// 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
/// When true the caller feeds PCM via `feedPcm` (e.g. the iOS VPIO mic path) and the core
/// skips opening a hardware capture device for this stream. Mirrors `vc_stream_desc.external_feed`.
public let externalFeed: Bool
public init(kind: VoiceCatStreamKind, deviceId: String? = nil, label: String,
externalFeed: Bool = false) {
self.kind = kind; self.deviceId = deviceId; self.label = label
self.externalFeed = externalFeed
}
}
/// 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
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,594 +0,0 @@
// Swift binding invariants: native config strings outlive the handle, destroy joins callback
// threads before deallocation, and callback payloads are copied before main-queue delivery.
// See docs/architecture.md §4 for the complete binding contract.
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
/// Swift-idiomatic alias for the C `vc_mixed_output_cb` function-pointer type from `voicecat.h`
/// the external mixed-output sink used by the iOS VPIO path (see `setMixedOutputSink`).
public typealias VoiceCatMixedOutputCallback = vc_mixed_output_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
private var handle: OpaquePointer?
/// Unretained callback context; destroying the handle joins callback threads first.
private var selfPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(self).toOpaque()
}
/// The core retains these pointers for the handle's lifetime.
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)?
private let bufferLock = NSLock()
private var eventBuffer: [VoiceCatEvent] = []
private var levelSamples: [UInt32: Float] = [:]
private var drainScheduled = false
// MARK: - Init / deinit
public init(config: VoiceCatConfig) {
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
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
/// 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))
}
@discardableResult
public func joinVoice() -> VoiceCatResult {
VoiceCatResult(vc_join_voice(handle))
}
@discardableResult
public func leaveVoice() -> VoiceCatResult {
VoiceCatResult(vc_leave_voice(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)
desc.external_feed = descriptor.externalFeed ? 1 : 0
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))
}
/// External mixed-output sink (iOS VPIO) receives the FINAL mixed remote audio as int16
/// PCM on the core's mixer-timer thread when external playback is enabled. The Swift VPIO
/// renderer copies this into its ring and plays it through the voice-processing output so
/// echo cancellation has its reference signal. Pass `nil` to disable. Mirrors
/// `vc_set_mixed_output_sink`. The callback MUST NOT block or allocate.
@discardableResult
public func setMixedOutputSink(_ cb: VoiceCatMixedOutputCallback?,
user: UnsafeMutableRawPointer?) -> VoiceCatResult {
VoiceCatResult(vc_set_mixed_output_sink(handle, cb, user))
}
/// Enable/disable external-playback mode (iOS VPIO). When enabled, the core opens NO hardware
/// playback device; it drives decode+mix on a timer and delivers the final mix via
/// `setMixedOutputSink`. Apply before the engine starts, or follow with `audioRestart()` to
/// apply to a running engine. Mirrors `vc_set_external_playback`.
@discardableResult
public func setExternalPlayback(_ enabled: Bool) -> VoiceCatResult {
VoiceCatResult(vc_set_external_playback(handle, enabled ? 1 : 0))
}
@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`.
@discardableResult
public func setOutputVolume(_ gain: Float) -> VoiceCatResult {
VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain))
}
/// Send-side microphone input gain. Applied to captured MIC PCM before the VAD/PTT gate and
/// Opus encode (so boosting a quiet mic also helps it cross the VAD threshold). gain 0.0 =
/// silent, 1.0 = unity (default), >1.0 amplifies (clamped to int16). Always LOCAL.
@discardableResult
public func setInputGain(_ gain: Float) -> VoiceCatResult {
VoiceCatResult(vc_set_input_gain(handle, gain < 0 ? 0 : gain))
}
/// Send-side microphone noise suppression (RNNoise). Denoises captured MIC PCM before the
/// input gain and VAD/PTT gate, so everyone hears the cleaned signal (one pass for all
/// listeners). MIC stream only, mono only; always LOCAL no protocol traffic. Independent
/// of the per-listener receive-side NR in `setRemoteStream` (docs/voice.md §10).
@discardableResult
public func setInputNoiseReduction(_ enable: Bool) -> VoiceCatResult {
VoiceCatResult(vc_set_input_noise_reduction(handle, enable ? 1 : 0))
}
// 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: - 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: - 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: - 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
n.dred = dred ? 1 : 0
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.
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 })")
// Permissions getter round-trip.
let perms = client.getPermissions()
XCTAssertFalse(perms.isAdmin)
XCTAssertFalse(perms.canKick)
// 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.ResumeForeground();
}
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(); }
}
}
@@ -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,268 @@
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;
private long renderCallbacks, lastRenderTimestamp;
internal bool IsConnected { get; private set; }
internal bool IsRunning => engine?.Running == true;
// The watchdog compares this across ticks: a graph that stops calling back while it still
// reports Running leaves the whole device-clocked pipeline frozen until it is rebuilt.
internal long RenderCallbacks => Interlocked.Read(ref renderCallbacks);
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.
Interlocked.Increment(ref renderCallbacks);
long now = System.Diagnostics.Stopwatch.GetTimestamp(), previous = lastRenderTimestamp;
lastRenderTimestamp = now;
VoiceCatClient? owner = Volatile.Read(ref client);
if (owner is null || !IsConnected) microphoneCredit = 0;
else
{
if (previous != 0 && (now - previous) * 1000.0 / System.Diagnostics.Stopwatch.Frequency > 100)
{
Volatile.Read(ref microphone)?.Ring.Resynchronize();
playbackRing.Resynchronize(); owner.Audio.ResynchronizeInputs(); microphoneCredit = 0;
}
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. Catch-up is
// capped at one extra cycle so a refill cannot overrun this callback's deadline.
int deficit = Math.Min(frames + playbackRing.TargetFrames - playbackRing.CountFrames, frames + 960);
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; lastRenderTimestamp = 0; microphoneCredit = 0;
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,296 @@
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;
private System.Threading.Timer? watchdog;
private long lastRenderCallbacks = -1;
private int watchdogMisses;
private bool watchdogTicking;
private bool interrupted;
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();
ResetWatchdog(); EnsureWatchdog();
}
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()
{
watchdog?.Dispose(); watchdog = null; ResetWatchdog();
AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out _);
}
internal void EnsureAudio(string reason)
{
if (!IosAudioEngine.Shared.IsConnected || interrupted) 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 || interrupted) return;
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{
try { IosAudioEngine.Shared.Reconfigure(); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); }
});
}
// The render callback is the only clock for the device-clocked pipeline, so a graph that
// stops while the app is backgrounded freezes capture, mix and send with no notification to
// recover from. Poll for that and rebuild; a failed rebuild is retried on the next tick.
private void EnsureWatchdog()
{
watchdog ??= new System.Threading.Timer(_ => UIApplication.SharedApplication.BeginInvokeOnMainThread(TickWatchdog),
null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
private void ResetWatchdog() { lastRenderCallbacks = -1; watchdogMisses = 0; }
// An interruption that ends while the app is suspended never delivers its Ended notification.
internal void ResumeForeground() { interrupted = false; ResetWatchdog(); Recover("foreground"); }
private void TickWatchdog()
{
if (watchdogTicking) return;
watchdogTicking = true;
try
{
if (!IosAudioEngine.Shared.IsConnected || interrupted || applying) { ResetWatchdog(); return; }
long callbacks = IosAudioEngine.Shared.RenderCallbacks;
bool stalled = !IosAudioEngine.Shared.IsRunning || callbacks == lastRenderCallbacks;
lastRenderCallbacks = callbacks;
if (!stalled) { watchdogMisses = 0; return; }
// One missed tick can be a route change already rebuilding the graph.
if (++watchdogMisses < 2) return;
ResetWatchdog();
try { IosAudioEngine.Shared.Reconfigure(); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio watchdog rebuild failed: {exception}"); }
}
finally { watchdogTicking = false; }
}
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);
// The system stops the graph on Began and SetActive fails until the interruption clears,
// so suppress recovery until Ended and let the watchdog retry if that rebuild fails.
if (interruption == AVAudioSessionInterruptionType.Began) { interrupted = true; ResetWatchdog(); return; }
if (interruption != AVAudioSessionInterruptionType.Ended) return;
interrupted = false; ResetWatchdog(); 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,616 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
AAAA00000000000000000002 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000001 /* Assets.xcassets */; };
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 */; };
BBBB00000000000000000F02 /* IOSVoiceProcessingEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000F01 /* IOSVoiceProcessingEngine.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 */
AAAA00000000000000000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
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>"; };
BBBB00000000000000000F01 /* IOSVoiceProcessingEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSVoiceProcessingEngine.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 = (
AAAA00000000000000000001 /* Assets.xcassets */,
BBBB00000000000000000015 /* Info.plist */,
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */,
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */,
BBBB00000000000000000018 /* AppState.swift */,
BBBB00000000000000000019 /* SessionState.swift */,
BBBB0000000000000000001A /* AudioSessionManager.swift */,
BBBB0000000000000000002F /* IOSAudioRouter.swift */,
BBBB00000000000000000F01 /* IOSVoiceProcessingEngine.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 = (
AAAA00000000000000000002 /* Assets.xcassets in Resources */,
);
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 */,
BBBB00000000000000000F02 /* IOSVoiceProcessingEngine.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 = me.iamtalon.voicecat;
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 = me.iamtalon.voicecat;
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 = me.iamtalon.voicecat.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 = me.iamtalon.voicecat.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,402 +0,0 @@
import Foundation
import Network
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
/// Retained after authentication so an interrupted session can be restored.
private var connectedServer: SavedServer?
// MARK: - Reconnect state
/// Distinguishes an explicit disconnect from a transport failure.
private var userInitiatedDisconnect = false
private struct LastSession {
let server: SavedServer
let channelId: UInt32
let voiceSubscribed: Bool
let micMuted: Bool
let deafened: Bool
}
private var lastSession: LastSession?
private var reconnectAttempt = 0
private var reconnectTask: Task<Void, Never>?
/// Detects interface changes before TCP keepalive notices a dead path.
private var pathMonitor: NWPathMonitor?
private let pathQueue = DispatchQueue(label: "cat.voice.network.path")
private var lastPathSignature: String?
// 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) {
connectTo(server, restoring: nil)
}
private func connectTo(_ server: SavedServer, restoring: LastSession?) {
guard !isConnecting else { return }
isConnecting = true
connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
connectingServer = server
identityHandled = false
userInitiatedDisconnect = false
// Releasing the wrapper joins the core's I/O thread before freeing native strings.
connectingClient = nil
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, restoring: restoring)
}
}
// Authentication can start audio, so select the external path before connecting.
client.setExternalPlayback(true)
client.connect(host: server.host, port: server.port)
switch server.authMode {
case .guest:
let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
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() {
// Set before disconnect so its event cannot arm reconnect.
userInitiatedDisconnect = true
cancelReconnect()
lastSession = nil
session?.leaveVoice()
session?.client.disconnect()
IOSAudioEngine.shared.stop()
AudioSessionManager.shared.deactivateSession()
session = nil
connectingClient?.disconnect()
connectingClient = nil
connectingServer = nil
connectedServer = 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() {
// User explicitly cancelled no reconnect for the resulting .disconnected event.
userInitiatedDisconnect = true
cancelReconnect()
lastSession = nil
connectingClient?.disconnect()
connectingClient = nil
connectingServer = nil
connectedServer = nil
isConnecting = false
connectStatus = ""
showPasswordPrompt = false
pendingIdentity = nil
}
// MARK: - Reconnect orchestration
private func cancelReconnect() {
reconnectTask?.cancel()
reconnectTask = nil
stopPathMonitor()
}
/// Schedules the next reconnect with exponential backoff capped at 30 seconds.
private func scheduleReconnect() {
guard !userInitiatedDisconnect, let last = lastSession else { return }
reconnectTask?.cancel()
reconnectAttempt = max(1, reconnectAttempt + 1)
let delaySec = min(pow(2.0, Double(reconnectAttempt - 1)), 30.0)
connectStatus = "Reconnecting (attempt \(reconnectAttempt))…"
startPathMonitor()
let task = Task { [weak self, last] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(delaySec * 1_000_000_000))
if Task.isCancelled { return }
guard !self.userInitiatedDisconnect else { return }
guard self.lastSession != nil else { return }
guard self.session == nil else { return }
self.connectTo(last.server, restoring: last)
}
reconnectTask = task
}
private func startPathMonitor() {
guard pathMonitor == nil else { return }
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor [weak self] in
guard let self else { return }
guard !self.userInitiatedDisconnect else { return }
let sig = Self.pathSignature(path)
let prevSig = self.lastPathSignature
self.lastPathSignature = sig
if prevSig == nil { return }
if self.session != nil {
if path.status != .satisfied || sig != prevSig {
self.proactiveReconnect()
}
} else if self.lastSession != nil {
if path.status == .satisfied {
self.reconnectAttempt = 0
self.scheduleReconnect()
}
}
}
}
monitor.start(queue: pathQueue)
pathMonitor = monitor
}
private func stopPathMonitor() {
pathMonitor?.cancel()
pathMonitor = nil
lastPathSignature = nil
}
private static func pathSignature(_ path: NWPath) -> String {
guard path.status == .satisfied else { return "unsatisfied" }
var parts: [String] = []
if path.usesInterfaceType(.wifi) { parts.append("wifi") }
if path.usesInterfaceType(.cellular) { parts.append("cellular") }
if path.usesInterfaceType(.wiredEthernet) { parts.append("wired") }
if path.usesInterfaceType(.other) { parts.append("other") }
return parts.isEmpty ? "none" : parts.sorted().joined(separator: "+")
}
// MARK: - Live-session disconnect (called by SessionState)
/// Receives disconnects after `SessionState` takes ownership of authenticated events.
func onLiveSessionDisconnected() {
guard !userInitiatedDisconnect else { return }
teardownLiveSessionAndReconnect(sound: false)
}
private func proactiveReconnect() {
guard !userInitiatedDisconnect else { return }
guard session != nil else { return }
teardownLiveSessionAndReconnect(sound: true)
}
private func teardownLiveSessionAndReconnect(sound: Bool) {
if let s = session, let srv = connectedServer {
lastSession = LastSession(
server: srv,
channelId: s.currentChannelId,
voiceSubscribed: s.voiceState.voiceSubscribed,
micMuted: s.voiceState.selfMuted,
deafened: s.voiceState.selfDeafened)
}
IOSAudioEngine.shared.stop()
AudioSessionManager.shared.deactivateSession()
session = nil
isConnecting = false
connectingClient = nil
connectedServer = nil
if sound {
EventFeedback.shared.play(.connectionLost)
EventFeedback.shared.speak("Network changed — reconnecting")
}
reconnectAttempt = 0
scheduleReconnect()
}
// MARK: - Connect event handler
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer,
restoring: LastSession?) {
switch ev.type {
case .connectionState:
switch ev.connectionState {
case .connecting: connectStatus = (restoring != nil) ? "Reconnecting…" : "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)
newSession.appState = self
connectingClient = nil
isConnecting = false
connectStatus = ""
showPasswordPrompt = false
connectedServer = server
self.session = newSession
EventFeedback.shared.play(.login)
EventFeedback.shared.speak(restoring != nil ? "Reconnected" : "Connected")
// External-playback mode was enabled before connect() so the core never opens a
// miniaudio device on iOS (the single ordering rule of the unified audio path).
// Now activate the session and start the engine in listening mode so remote audio
// plays the moment someone talks, even before we join voice (no "can't hear anyone").
do {
try AudioSessionManager.shared.ensureSessionActive()
} catch {
print("Audio session activate on connect failed: \(error)")
}
IOSAudioEngine.shared.startListening(client: client)
// The path monitor runs the whole time we're connected so a network change fires
// proactiveReconnect immediately instead of waiting for the C core's TCP keepalive
// timeout (~30-60 s on a hard Wi-Fi drop). It stays armed across reconnects and is
// stopped only on user-initiated disconnect.
startPathMonitor()
// Reconnect restore: rejoin the prior channel and re-enable voice/mic if they
// were on. The session is fresh (server auto-places us in Lobby), so the restore
// is driven through SessionState.requestRestore, which issues a JoinChannel then
// (on the resulting .joinResult) re-arms voice + mute/deafen. A successful auth
// means the server is reachable, so the backoff counter resets and `lastSession`
// clears; the path monitor keeps watching for the next change.
if let restoring {
newSession.requestRestore(channelId: restoring.channelId,
voiceSubscribed: restoring.voiceSubscribed,
micMuted: restoring.micMuted,
deafened: restoring.deafened)
reconnectAttempt = 0
lastSession = nil
}
} else {
connectStatus = "Auth failed: \(ev.result.description)"
showPasswordPrompt = true
}
case .disconnected:
// This handler runs ONLY during the connecting phase after auth success
// `SessionState.init` overwrites `client.onEvent`, so a live-session disconnect
// reaches `SessionState.handleEvent` and comes back via
// `onLiveSessionDisconnected`, not here. Two outcomes for this branch:
// - A reconnect's connecting phase failed (`lastSession != nil`, set by a prior
// teardown) re-arm `scheduleReconnect` so the backoff loop continues.
// - A fresh connect failed before auth (`lastSession == nil`) show the error, do
// not auto-reconnect (the user should retry manually once the server is reachable).
connectingClient = nil
isConnecting = false
IOSAudioEngine.shared.stop()
AudioSessionManager.shared.deactivateSession()
if userInitiatedDisconnect {
connectStatus = ""
showPasswordPrompt = false
pendingIdentity = nil
lastSession = nil
connectedServer = nil
cancelReconnect()
} else if lastSession != nil {
// Mid-reconnect drop keep the backoff loop going.
EventFeedback.shared.play(.connectionLost)
EventFeedback.shared.speak("Connection lost — reconnecting")
scheduleReconnect()
} else {
// Fresh connect failed before auth. Surface the reason; no auto-reconnect.
connectStatus = ev.text ?? "Disconnected"
showPasswordPrompt = false
pendingIdentity = nil
connectedServer = nil
cancelReconnect()
}
case .error:
connectStatus = ev.text ?? "Unknown error"
// Errors don't disconnect us; the .disconnected event handles teardown/reconnect.
default:
break
}
}
}

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