Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb740bcfb2 | ||
|
|
186fe6dbb6 | ||
|
|
f348574bc1 | ||
|
|
1487f2673b | ||
|
|
ea76d5157a | ||
|
|
7b0c003ad4 | ||
|
|
7c4708c3de | ||
|
|
8471eac363 | ||
|
|
4aa0aa4fbd | ||
|
|
0372baf589 | ||
|
|
35617e9708 | ||
|
|
cf808483e0 | ||
|
|
4fac7af140 | ||
|
|
239e8e9013 | ||
|
|
6de95ddaa1 | ||
|
|
9be33f357d | ||
|
|
08e6c5930a | ||
|
|
dd811a0bb8 | ||
|
|
3ee0df11a9 | ||
|
|
57a63cc1cd | ||
|
|
b79cbeff44 | ||
|
|
c9ed832459 | ||
|
|
42e3bbe14c | ||
|
|
9fc5598e8e | ||
|
|
c6715028c1 | ||
|
|
d0a72176ba | ||
|
|
310c0f09dd | ||
|
|
ab460ae12b | ||
|
|
28f1dbb991 | ||
|
|
5a30018aeb | ||
|
|
52f7f51e59 | ||
|
|
edd7783a5c | ||
|
|
e5ff484cac | ||
|
|
7be93e81a1 | ||
|
|
48c754aed9 | ||
|
|
82ad4c2811 | ||
|
|
5a226ba543 | ||
|
|
653131b876 | ||
|
|
274b85025c | ||
|
|
05eacb3092 | ||
|
|
4067bab7c2 | ||
|
|
2df79cdd4c | ||
|
|
b76181d9fb | ||
|
|
c6c003b8a7 | ||
|
|
4f71b784fe | ||
|
|
575e2907d0 | ||
|
|
f687370e47 | ||
|
|
552ffceb66 | ||
|
|
a4b3838125 | ||
|
|
eafa5eb90c | ||
|
|
158a2df062 | ||
|
|
826b3bfb86 | ||
|
|
cc81d19d02 | ||
|
|
8844325efa | ||
|
|
bd844e4710 | ||
|
|
5c03e5f261 | ||
|
|
bba605401d | ||
|
|
bda37ec27b | ||
|
|
9612b0af89 | ||
|
|
2c8178fa02 | ||
|
|
9a20953c08 | ||
|
|
99937c9446 | ||
|
|
44a336cc89 | ||
|
|
6fe7bf0158 | ||
|
|
2baefddbe4 | ||
|
|
65736df464 | ||
|
|
8bb2ba933c | ||
|
|
7ef560ba8a | ||
|
|
47124b15a2 | ||
|
|
04bdb70d47 | ||
|
|
b44a200b95 | ||
|
|
f72219ddf3 | ||
|
|
b14cf2a4e8 | ||
|
|
19c2fb6ec9 | ||
|
|
cd9c08a47a | ||
|
|
2e0e0caccb | ||
|
|
bad9c7533a | ||
|
|
7249a8fd30 | ||
|
|
a48b47d4ca | ||
|
|
95f1fb70b0 | ||
|
|
d30c4ee2f5 | ||
|
|
7547b8e140 | ||
|
|
ce2035f271 | ||
|
|
e155e342f4 | ||
|
|
a460009a2f | ||
|
|
50416c33a2 | ||
|
|
725bd8e925 | ||
|
|
483f889910 | ||
|
|
5e18dfa1c9 | ||
|
|
fb73b694d0 | ||
|
|
6c17881cc0 | ||
|
|
e806b698ec | ||
|
|
4f89d2d32d | ||
|
|
6071c8e238 | ||
|
|
5be6d8430d | ||
|
|
0c6b1a36cf | ||
|
|
c1e6f4f7ff | ||
|
|
6b7f06a282 |
@@ -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
|
||||
@@ -0,0 +1,26 @@
|
||||
# Git history — large and never needed inside the build context
|
||||
.git/
|
||||
|
||||
# Previous build outputs
|
||||
build/
|
||||
artifacts/
|
||||
**/bin/
|
||||
**/obj/
|
||||
|
||||
# Native GUI client code (Swift/Xcode, C#/WinForms) — server build doesn't need these
|
||||
clients/
|
||||
|
||||
# Documentation and prose — not compiled
|
||||
docs/
|
||||
*.md
|
||||
AGENTS.md
|
||||
PROGRESS.md
|
||||
CLAUDE.md
|
||||
|
||||
# Editor / tooling config
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -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
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Build and test
|
||||
|
||||
on:
|
||||
push:
|
||||
paths: ['src/**', 'tests/**', 'clients/**', 'native/**', 'proto/**', 'assets/**', 'scripts/**', '*.slnx', '*.props', '*.targets', 'global.json', 'Dockerfile', '.github/workflows/dotnet.yml']
|
||||
pull_request:
|
||||
paths: ['src/**', 'tests/**', 'clients/**', 'native/**', 'proto/**', 'assets/**', 'scripts/**', '*.slnx', '*.props', '*.targets', 'global.json', 'Dockerfile', '.github/workflows/dotnet.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-24.04, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
global-json-file: global.json
|
||||
cache: true
|
||||
cache-dependency-path: '**/packages.lock.json'
|
||||
- name: Build and stage native codec/DSP
|
||||
shell: pwsh
|
||||
run: ./scripts/build-native.ps1
|
||||
- run: dotnet restore VoiceCat.slnx --locked-mode
|
||||
- run: dotnet build VoiceCat.slnx -c Release --no-restore
|
||||
- run: dotnet test VoiceCat.slnx -c Release --no-build
|
||||
- shell: pwsh
|
||||
run: ./scripts/check-licenses.ps1
|
||||
|
||||
apple-client:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
global-json-file: global.json
|
||||
cache: true
|
||||
cache-dependency-path: '**/packages.lock.json'
|
||||
- name: Install Apple workloads
|
||||
run: dotnet workload install macos ios --version 10.0.401
|
||||
- name: Build and stage native codec/DSP
|
||||
shell: pwsh
|
||||
run: ./scripts/build-native.ps1
|
||||
- name: Build and stage static iOS codec/DSP
|
||||
run: ./scripts/build-native-ios.sh
|
||||
- name: Restore managed Apple clients
|
||||
run: dotnet restore clients/apple/VoiceCat.Apple.slnx
|
||||
- name: Build managed AppKit and UIKit clients
|
||||
run: dotnet build clients/apple/VoiceCat.Apple.slnx -c Debug --no-restore
|
||||
+10
-8
@@ -1,4 +1,8 @@
|
||||
# Build output
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/TestResults/
|
||||
/artifacts/
|
||||
/build/
|
||||
/out/
|
||||
|
||||
@@ -14,9 +18,7 @@
|
||||
*.exe
|
||||
*.pdb
|
||||
|
||||
# vcpkg
|
||||
/vcpkg_installed/
|
||||
/vcpkg/
|
||||
# vcpkg (bundled as a submodule at /vcpkg — see docs/building.md §2)
|
||||
|
||||
# Generated protobuf
|
||||
*.pb.cc
|
||||
@@ -36,18 +38,18 @@
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Apple / Windows client build artifacts (added in M4)
|
||||
# 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)
|
||||
|
||||
@@ -1,109 +1,52 @@
|
||||
# AGENTS.md — working method
|
||||
# VoiceCat working method
|
||||
|
||||
This file is the **working method** for a developer or AI agent picking up VoiceCat. Companion
|
||||
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):
|
||||
## Core verification
|
||||
|
||||
```bash
|
||||
export VCPKG_ROOT=/path/to/vcpkg # bootstrap vcpkg first; cross-platform
|
||||
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
|
||||
```
|
||||
|
||||
Skeleton (no third-party deps — works immediately, no vcpkg needed):
|
||||
Apple builds additionally use `./scripts/build-native-ios.sh` and
|
||||
`clients/apple/VoiceCat.Apple.slnx` on macOS.
|
||||
|
||||
```bash
|
||||
cmake --preset skeleton
|
||||
cmake --build --preset skeleton
|
||||
ctest --preset skeleton
|
||||
```
|
||||
## Hard rules
|
||||
|
||||
> **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).
|
||||
|
||||
## 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 §3–4 |
|
||||
| `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 §8–11 |
|
||||
| `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.
|
||||
|
||||
@@ -1,162 +1,49 @@
|
||||
# CLAUDE.md — agent hub for VoiceCat
|
||||
# VoiceCat developer guide
|
||||
|
||||
Auto-loaded each session. This is the **map**: build commands, architecture at a glance, and
|
||||
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 — 23/23 tests.
|
||||
> External PCM feed/tap API (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) shipped.
|
||||
> **Screen-audio sharing shipped on macOS (ScreenCaptureKit) and iOS (ReplayKit Broadcast
|
||||
> Upload Extension → host App Group ring → `vc_stream_feed_pcm`).** See [`PROGRESS.md`](PROGRESS.md).
|
||||
|
||||
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control)
|
||||
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives
|
||||
native clients (Swift on macOS/iOS, C# on Windows) and the server.
|
||||
|
||||
---
|
||||
|
||||
## Build & test commands
|
||||
|
||||
The default development preset is **`dev`** — it builds everything (server + tools + tests)
|
||||
with real vcpkg deps. The `skeleton` preset (no deps, stubs only) is a fast smoke check; see
|
||||
[`docs/building.md`](docs/building.md) for the full preset matrix.
|
||||
## 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`.
|
||||
|
||||
One-time vcpkg setup:
|
||||
## Current architecture
|
||||
|
||||
```bash
|
||||
# one-time: git clone https://github.com/microsoft/vcpkg && ./vcpkg/bootstrap-vcpkg.sh (.bat on Windows)
|
||||
export VCPKG_ROOT=/path/to/vcpkg # works on Linux / macOS / 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.
|
||||
|
||||
Other useful toggles (pass with `-D` at configure time):
|
||||
## Invariants
|
||||
|
||||
```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 §3–4 |
|
||||
| `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 §8–11 |
|
||||
| `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.
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.25)
|
||||
|
||||
project(voicecat
|
||||
VERSION 0.0.1
|
||||
DESCRIPTION "Self-hosted native voice & text chat (see docs/)"
|
||||
LANGUAGES CXX)
|
||||
|
||||
# On iOS, audio_engine.cpp includes miniaudio.h which pulls in AVFoundation Objective-C
|
||||
# headers. Those cannot be compiled as C++; we set audio_engine.cpp's LANGUAGE to OBJCXX
|
||||
# in core/CMakeLists.txt, but that requires the language to be enabled first.
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
|
||||
enable_language(OBJCXX)
|
||||
endif()
|
||||
|
||||
# ── Options ───────────────────────────────────────────────────────────────────
|
||||
# The M0 skeleton compiles with NO third-party dependencies: every subsystem is a
|
||||
# stub that returns VC_ERR_NOT_IMPLEMENTED. As each subsystem is built out, flip
|
||||
# VOICECAT_USE_VCPKG_DEPS=ON so CMake pulls the real libraries (mbedTLS, libsodium,
|
||||
# opus, protobuf, ...) via the vcpkg toolchain (see vcpkg.json / docs/tech-stack.md).
|
||||
option(VOICECAT_USE_VCPKG_DEPS "Link real third-party deps via vcpkg" OFF)
|
||||
option(VOICECAT_BUILD_SERVER "Build voicecat-server" ON)
|
||||
option(VOICECAT_BUILD_TOOLS "Build the vccli headless test client" ON)
|
||||
option(VOICECAT_BUILD_TESTS "Build tests" ON)
|
||||
option(VOICECAT_BUILD_SHARED "Build libvoicecat as a shared library" OFF)
|
||||
|
||||
# ── Global settings ───────────────────────────────────────────────────────────
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE)
|
||||
endif()
|
||||
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
|
||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
|
||||
|
||||
# ── Targets ───────────────────────────────────────────────────────────────────
|
||||
add_subdirectory(core)
|
||||
|
||||
if(VOICECAT_BUILD_SERVER)
|
||||
add_subdirectory(server)
|
||||
endif()
|
||||
|
||||
if(VOICECAT_BUILD_TOOLS)
|
||||
add_subdirectory(tools/vccli)
|
||||
if(VOICECAT_USE_VCPKG_DEPS)
|
||||
add_subdirectory(tools/voicecat-admin)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(VOICECAT_BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
|
||||
message(STATUS "VoiceCat ${PROJECT_VERSION} configured "
|
||||
"(vcpkg deps: ${VOICECAT_USE_VCPKG_DEPS}, "
|
||||
"server: ${VOICECAT_BUILD_SERVER}, tools: ${VOICECAT_BUILD_TOOLS})")
|
||||
@@ -1,147 +0,0 @@
|
||||
{
|
||||
"version": 6,
|
||||
"cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 },
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "vcpkg-common",
|
||||
"hidden": true,
|
||||
"description": "Shared base for all presets that link real deps via vcpkg. Uses cmake/voicecat-toolchain.cmake, which auto-resolves VCPKG_TARGET_TRIPLET / VCPKG_HOST_TRIPLET from the host platform (x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon). Cross-compile presets override VCPKG_TARGET_TRIPLET in their cacheVariables. Requires VCPKG_ROOT in the environment.",
|
||||
"generator": "Ninja",
|
||||
"toolchainFile": "${sourceDir}/cmake/voicecat-toolchain.cmake",
|
||||
"cacheVariables": {
|
||||
"VOICECAT_USE_VCPKG_DEPS": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "skeleton",
|
||||
"displayName": "Skeleton (no third-party deps)",
|
||||
"description": "Builds the stub skeleton with just a C++20 compiler — no vcpkg needed. Subsystems return VC_ERR_NOT_IMPLEMENTED. Good for 'does the repo even build' smoke checks. Runs 2 tests (smoke + frame_codec).",
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/build/skeleton",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug",
|
||||
"VOICECAT_USE_VCPKG_DEPS": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dev",
|
||||
"displayName": "Dev (full real-deps build, vcpkg)",
|
||||
"description": "Day-to-day development preset. Real protocol, crypto, voice, server — everything from M1 onward. Builds server + tools + tests (21 tests). Auto-triplet: x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon. Requires VCPKG_ROOT.",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/dev",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug",
|
||||
"VOICECAT_BUILD_TOOLS": "ON",
|
||||
"VOICECAT_BUILD_TESTS": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "release",
|
||||
"displayName": "Release (optimized, tests on, symbols kept)",
|
||||
"description": "Optimized build with the full test suite enabled. Use to run tests against optimized code, profile, or catch optimizer-sensitive bugs. Symbols are kept (not stripped) so stack traces and profiling remain useful. Auto-triplet. Requires VCPKG_ROOT.",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/release",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"VOICECAT_BUILD_TOOLS": "ON",
|
||||
"VOICECAT_BUILD_TESTS": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "server-release",
|
||||
"displayName": "Server Release (optimized, stripped, no tests)",
|
||||
"description": "Production-shaped build for deployment. Optimized (Release) with stripped binaries (-s linker flag), no tests. This is what you'd ship/run — see docs/deployment.md. Auto-triplet. Requires VCPKG_ROOT.",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/server-release",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"VOICECAT_BUILD_TOOLS": "ON",
|
||||
"VOICECAT_BUILD_TESTS": "OFF",
|
||||
"CMAKE_EXE_LINKER_FLAGS": "-s",
|
||||
"CMAKE_SHARED_LINKER_FLAGS": "-s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "windows-client",
|
||||
"displayName": "Windows client (voicecat.dll for C# WinForms, M4)",
|
||||
"description": "Produces a redistributable Release voicecat.dll with no MinGW runtime DLL dependencies (see core/CMakeLists.txt's static-runtime link flags and clients/windows/README.md). Server/tools/tests are off — this preset exists only to build the DLL. Windows only.",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/windows-client",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"VOICECAT_BUILD_SHARED": "ON",
|
||||
"VOICECAT_BUILD_SERVER": "OFF",
|
||||
"VOICECAT_BUILD_TOOLS": "OFF",
|
||||
"VOICECAT_BUILD_TESTS": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "apple-dev",
|
||||
"displayName": "Apple macOS (libvoicecat.a for Swift Package, scaffolding)",
|
||||
"description": "SCAFFOLDING — not yet CI-validated; build on macOS to verify. Produces a static libvoicecat.a for macOS (arm64-osx on Apple Silicon, x64-osx on Intel) for consumption by the Swift Package / XCFramework. Server/tools/tests off. Requires VCPKG_ROOT.",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/apple-dev",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"VOICECAT_BUILD_SERVER": "OFF",
|
||||
"VOICECAT_BUILD_TOOLS": "OFF",
|
||||
"VOICECAT_BUILD_TESTS": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "apple-ios",
|
||||
"displayName": "Apple iOS device (XCFramework slice)",
|
||||
"description": "Cross-compiles a static libvoicecat.a for iOS device (arm64). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios.cmake (release-only, correct autoconf host triple).",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/apple-ios",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"CMAKE_SYSTEM_NAME": "iOS",
|
||||
"CMAKE_SYSTEM_PROCESSOR": "arm64",
|
||||
"CMAKE_OSX_ARCHITECTURES": "arm64",
|
||||
"CMAKE_OSX_SYSROOT": "iphoneos",
|
||||
"CMAKE_OSX_DEPLOYMENT_TARGET": "17.0",
|
||||
"VCPKG_TARGET_TRIPLET": "arm64-ios",
|
||||
"VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-overlays/triplets",
|
||||
"VOICECAT_BUILD_SERVER": "OFF",
|
||||
"VOICECAT_BUILD_TOOLS": "OFF",
|
||||
"VOICECAT_BUILD_TESTS": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "apple-ios-sim",
|
||||
"displayName": "Apple iOS simulator (XCFramework slice)",
|
||||
"description": "Cross-compiles a static libvoicecat.a for iOS simulator (arm64-ios-simulator). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS simulator SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake (release-only, correct autoconf host triple).",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/apple-ios-sim",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"CMAKE_SYSTEM_NAME": "iOS",
|
||||
"CMAKE_SYSTEM_PROCESSOR": "arm64",
|
||||
"CMAKE_OSX_ARCHITECTURES": "arm64",
|
||||
"CMAKE_OSX_SYSROOT": "iphonesimulator",
|
||||
"CMAKE_OSX_DEPLOYMENT_TARGET": "17.0",
|
||||
"VCPKG_TARGET_TRIPLET": "arm64-ios-simulator",
|
||||
"VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-overlays/triplets",
|
||||
"VOICECAT_BUILD_SERVER": "OFF",
|
||||
"VOICECAT_BUILD_TOOLS": "OFF",
|
||||
"VOICECAT_BUILD_TESTS": "OFF"
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
{ "name": "skeleton", "configurePreset": "skeleton" },
|
||||
{ "name": "dev", "configurePreset": "dev" },
|
||||
{ "name": "release", "configurePreset": "release" },
|
||||
{ "name": "server-release", "configurePreset": "server-release" },
|
||||
{ "name": "windows-client", "configurePreset": "windows-client" },
|
||||
{ "name": "apple-dev", "configurePreset": "apple-dev" },
|
||||
{ "name": "apple-ios", "configurePreset": "apple-ios" },
|
||||
{ "name": "apple-ios-sim", "configurePreset": "apple-ios-sim" }
|
||||
],
|
||||
"testPresets": [
|
||||
{ "name": "skeleton", "configurePreset": "skeleton", "output": { "outputOnFailure": true } },
|
||||
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
||||
{ "name": "release", "configurePreset": "release", "output": { "outputOnFailure": true } }
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == '' and '$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</VoiceCatNativeRid>
|
||||
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</VoiceCatNativeRid>
|
||||
<VoiceCatNativeDirectory Condition="'$(VoiceCatNativeDirectory)' == ''">$(MSBuildThisFileDirectory)artifacts/native/runtimes/$(VoiceCatNativeRid)/native</VoiceCatNativeDirectory>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition="'$(MSBuildProjectName)' != 'VoiceCat.Server' and '$(MSBuildProjectName)' != 'VoiceCat.Crypto' and '$(MSBuildProjectName)' != 'VoiceCat.Protocol'">
|
||||
<None Include="$(MSBuildThisFileDirectory)artifacts/native/licenses/*.txt" Link="licenses/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
<None Include="$(VoiceCatNativeDirectory)/voicecat_media.dll" Condition="Exists('$(VoiceCatNativeDirectory)/voicecat_media.dll')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
<None Include="$(VoiceCatNativeDirectory)/libvoicecat_media.so" Condition="Exists('$(VoiceCatNativeDirectory)/libvoicecat_media.so')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
<None Include="$(VoiceCatNativeDirectory)/libvoicecat_media.dylib" Condition="Exists('$(VoiceCatNativeDirectory)/libvoicecat_media.dylib')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0.203-noble AS build
|
||||
WORKDIR /src
|
||||
COPY global.json Directory.Build.props Directory.Build.targets ./
|
||||
COPY proto/voicecat.proto proto/voicecat.proto
|
||||
COPY src/ src/
|
||||
RUN dotnet restore src/VoiceCat.Server/VoiceCat.Server.csproj -r linux-x64 --locked-mode -p:NuGetLockFilePath=packages.publish.linux-x64.lock.json
|
||||
RUN dotnet publish src/VoiceCat.Server/VoiceCat.Server.csproj -c Release -r linux-x64 --self-contained true --no-restore \
|
||||
-p:PublishSingleFile=true -p:PublishTrimmed=false -p:IncludeNativeLibrariesForSelfExtract=true -o /out \
|
||||
&& mkdir /empty-data
|
||||
|
||||
FROM scratch AS export
|
||||
COPY --from=build /out/VoiceCat.Server /VoiceCat.Server
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0.10-noble-chiseled-extra AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build --chown=1654:1654 /out/ ./
|
||||
COPY --from=build --chown=1654:1654 /empty-data/ /data/
|
||||
USER 1654:1654
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8384/tcp
|
||||
EXPOSE 8384/udp
|
||||
ENV VOICECAT_DATA_DIR=/data \
|
||||
VOICECAT_BIND_ADDRESS=0.0.0.0 \
|
||||
VOICECAT_BIND_PORT=8384
|
||||
HEALTHCHECK --interval=30s --timeout=6s --start-period=10s --retries=3 CMD ["/app/VoiceCat.Server", "--health-check", "127.0.0.1:8384"]
|
||||
ENTRYPOINT ["/app/VoiceCat.Server"]
|
||||
+59
-546
@@ -1,546 +1,59 @@
|
||||
# PROGRESS — VoiceCat
|
||||
|
||||
Living status. **Update this file in the same commit as your work** so the next agent picks
|
||||
up instantly. Newest status at the top.
|
||||
|
||||
- **Date convention:** ISO (YYYY-MM-DD).
|
||||
- Statuses: `[ ]` not started · `[~]` in progress · `[x]` done.
|
||||
|
||||
---
|
||||
|
||||
## ▶ Where we left off / next action
|
||||
|
||||
- **Done (2026-06-21):** **iOS iPhone-layout UX fixes.** (1) Channels are now a **drill-down**
|
||||
on iPhone — new `ChannelBrowserView` (root list of top-level channels) → `ChannelDetailView`
|
||||
(people in the channel + sub-channels + an explicit "Join Channel" button with password
|
||||
prompt). The iPad 3-column `NavigationSplitView` is unchanged. (2) Extracted a self-contained
|
||||
`UserRow` (context menu + sheets) from `UserListView` so admin actions are reused in the
|
||||
drill-down. (3) Fixed the **off-screen chat compose box**: `MainView` now places
|
||||
`VoiceControlsView` via `.safeAreaInset(edge: .bottom)` instead of a floating `.overlay`, so
|
||||
it reserves layout space above the tab bar and cooperates with keyboard avoidance. (4)
|
||||
**Collapsed Activity into Chat** like macOS/Windows: `ChatView` renders a merged, time-sorted
|
||||
timeline of `messages` + `activityLog` (activity rows in gray); the separate Activity tab and
|
||||
`ActivityLogView.swift` are removed. `xcodebuild` Debug for `generic/platform=iOS` BUILD
|
||||
SUCCEEDED (sim slice still arm64-only → simulator run N/A). Next: on-device check of the
|
||||
drill-down + compose box + unified timeline.
|
||||
|
||||
- **Done (2026-06-21):** **Screen-audio sharing on macOS + iOS.** macOS uses ScreenCaptureKit
|
||||
(`ScreenAudioCapture.swift`) → `vc_stream_feed_pcm`; iOS uses a ReplayKit Broadcast Upload
|
||||
Extension (`VoiceCatBroadcast`) that forwards captured `.audioApp` PCM through a shared App
|
||||
Group SPSC ring (`BroadcastAudioRing.swift`) to the host's `BroadcastAudioPump`, which owns
|
||||
the `SCREEN_AUDIO` stream and feeds it — single session, no creds on disk. No C++ changes
|
||||
(the core was already ready). macOS `xcodebuild` Debug BUILD SUCCEEDED; iOS app + extension
|
||||
build for device (the xcframework sim slice is arm64-only, so x86_64-simulator link is N/A).
|
||||
Next: on-device end-to-end verification (two clients hear the shared audio; iOS broadcast
|
||||
start/stop). NOTE: `ctest --preset dev` is 22/23 — `external_pcm` passes its assertions but
|
||||
aborts at shutdown (`mutex lock failed`), a **pre-existing** teardown crash unrelated to this
|
||||
change (no C++ was modified).
|
||||
|
||||
- **Done (2026-06-20):** **macOS client UI overhaul** — mirrors the Windows client's UI
|
||||
overhaul (commit 97fa659 + 540ec13), adapted to Mac-native conventions. Also fixed and
|
||||
verified the previously-uncompiled Swift changes from the external PCM feed/tap commit
|
||||
(615d2a8). The main window is now just toolbar + channels + users + chat; audio device
|
||||
settings (input mode, VAD, PTT key, device picker, level meter) moved to a modeless
|
||||
Settings window (⌘,). Details in M5 section below. `swift test` 10/10; `xcodebuild` Debug
|
||||
+ Release BUILD SUCCEEDED with 0 Swift warnings.
|
||||
Next: live manual verification (toolbar toggles, unified log colors, PM windows, channel
|
||||
counts, volume slider, settings window); then iOS ReplayKit and macOS ScreenCaptureKit
|
||||
consumers of `vc_stream_feed_pcm`.
|
||||
|
||||
- **Awaiting on-device verification:** **iOS stereo mic kills headphone/A2DP output — REAL
|
||||
root cause found & fixed** (2026-06-20, on Windows; verify on Mac). All prior "fixes" (the
|
||||
2026-06-19 entries below) targeted the Swift `IOSAudioRouter` on the false premise that
|
||||
"miniaudio does NOT touch AVAudioSession on iOS." **It does.** The core opened its miniaudio
|
||||
devices with `ma_device_init(nullptr, ...)`; with a NULL context, miniaudio (0.11.25) runs an
|
||||
iOS "hack" (`miniaudio.h` ~44057) that picks a session category by device type, then
|
||||
`ma_context_init__coreaudio` (~36552) calls `setCategory()` + `setActive()` on **every device
|
||||
open** — capture → `AVAudioSessionCategoryRecord` with **zero options**. That wiped the
|
||||
`.playAndRecord` category, the mode, and `.allowBluetoothA2DP`/`.mixWithOthers`/`.allowAirPlay`
|
||||
that `IOSAudioRouter` had just configured → headphone/A2DP (and even wired) output died. The
|
||||
stereo presets broke worst because they depend on the A2DP output route the wipe removed.
|
||||
TeamTalk never hits this: its SDK opens RemoteIO/VPIO AudioUnits directly and leaves the
|
||||
session entirely to the app (`UtilSound.swift`); miniaudio insists on managing it.
|
||||
- **Fix (core, cross-platform safe):** `AudioEngine` now owns a `ma_context` built by
|
||||
`make_context_config()` with `coreaudio.sessionCategory = ma_ios_session_category_none` +
|
||||
`noAudioSessionActivate`/`noAudioSessionDeactivate = MA_TRUE`, and passes it to **all**
|
||||
`ma_device_init` calls (playback, capture, loopback) and to `enumerate_devices`'s context.
|
||||
miniaudio now never touches AVAudioSession; the Swift `IOSAudioRouter` is the sole owner
|
||||
(session is already activated on connect in `AppState.swift:authResult`, before any device
|
||||
opens, so removing miniaudio's self-activation is safe). Context is lazily inited in
|
||||
`start()`, reused across restarts, uninited in `~AudioEngine`.
|
||||
Files: `core/src/audio/audio_engine.{h,cpp}`.
|
||||
- **TEMP diagnostics (remove after verification):** `AudioSessionManager.logSessionState(_:)`
|
||||
logs category/mode/options/route; called after `ensureSessionActive`, on every route change,
|
||||
and on `.streamStarted` (right after the core opens its devices). On Mac, watch the log when
|
||||
joining voice with the Stereo Mic preset: category must stay `…PlayAndRecord` with
|
||||
`allowBluetoothA2DP` and the output route must remain the headphones/A2DP device — NOT flip
|
||||
to `…Record`. If confirmed, delete the `logSessionState` calls + method and the prior
|
||||
band-aid comments in `IOSAudioRouter`/`audio_engine.cpp` can be trimmed.
|
||||
- **Verified on Windows:** `cmake --build --preset dev` clean, `ctest --preset dev` 23/23
|
||||
(22/22 prior + `test_external_pcm` new binary). iOS build & on-device run still to be done by the user on the Mac.
|
||||
|
||||
- **Done (2026-06-20):** **External PCM feed/tap API (`vc_stream_feed_pcm` +
|
||||
`vc_set_pcm_sink`)** — see detail in M5 section below. `ctest --preset dev` 23/23 (was 22/22 + 1 new test binary with 3 sub-tests).
|
||||
Next: iOS ReplayKit and macOS ScreenCaptureKit consumers of this API. A public,
|
||||
documented API for driving audio streams with externally-provided PCM instead of (or in
|
||||
addition to) miniaudio's hardware device. Motivated by four concrete use cases — all in our
|
||||
roadmap — that the current "miniaudio owns the device" model can't serve:
|
||||
1. **ReplayKit Broadcast Upload Extension (iOS `SCREEN_AUDIO`)** — the extension is a
|
||||
*separate process* with a ~50 MB memory cap and can't link the full `AudioEngine`
|
||||
(`ma_device`, capture/playback threads). It needs to feed `CMSampleBuffer` audio (system
|
||||
app audio) into the encode path without any audio hardware. The current plan in
|
||||
`docs/voice.md §9` says the extension links "a minimal slice of the core (Opus encode +
|
||||
media send only)" — a public feed-PCM API *is* that minimal slice. The extension links
|
||||
Opus + the feed entry point, no `ma_device` needed.
|
||||
2. **ScreenCaptureKit (macOS `SCREEN_AUDIO`)** — `SCStream` delivers `CMSampleBuffer` in a
|
||||
callback; convert to int16 and feed. No need to route through miniaudio's device layer.
|
||||
**This is how macOS screen-audio actually gets implemented** — today it does NOT work:
|
||||
`VOICECAT_HAS_LOOPBACK` is Windows-only (`core/CMakeLists.txt:88-95`), so on macOS
|
||||
`AudioEngine::start_loopback_capture()` hits the `#else` stub (`audio_engine.cpp:647-649`)
|
||||
and returns `false`. The macOS client's "Share Screen Audio" button
|
||||
(`MainWindowController.swift:800-816`) calls `startStream(.screenAudio)` which announces
|
||||
the stream to peers but captures **zero audio** — peers hear silence. The button is left
|
||||
in place (not touched per user request); it'll work once this API + a ScreenCaptureKit
|
||||
tap ship on Mac.
|
||||
3. **Bots** — music bot, TTS bot, radio relay, transcription bot. They create a
|
||||
`SCREEN_AUDIO`/`AUX_DEVICE` stream and feed synthesized or decoded PCM via the feed API.
|
||||
No audio hardware required — runs headless on a server. Today the only way to feed
|
||||
external PCM is `vc_test_inject_capture` (TEST-ONLY, name signals "don't ship this") or
|
||||
re-implementing Opus encode + AEAD + UDP framing yourself (~500 lines of duplicated
|
||||
crypto/codec code per consumer).
|
||||
4. **Custom clients / accessibility** — soundboard, DAW integration, TTS of incoming chat,
|
||||
recording/transcription of remote audio. Need either feed (send) or tap (receive) or
|
||||
both.
|
||||
|
||||
**What we already have (input half, gated as test-only):** `vc_test_inject_capture
|
||||
(stream_id, pcm, samples)` (`voicecat.h`, `client.cpp:1452`) feeds raw int16 PCM into the
|
||||
encode pipeline via `AudioEngine::inject_capture(kind, pcm, n)`. It works for any stream
|
||||
kind, supports multiple concurrent injection taps (one ring buffer per local kind), and
|
||||
goes through the full encode → AEAD → UDP path. The encode path already handles
|
||||
`channels == 1 || 2` (proven by the WASAPI stereo loopback work, 2026-06-17 entry below).
|
||||
The only problems: it's marked TEST-ONLY in the header, the name signals "don't use this in
|
||||
production," and it hardcodes mono (no `channels` parameter).
|
||||
|
||||
**What's missing (output half):** today decoded remote audio is mixed and pushed to the
|
||||
miniaudio playback device (`on_playback`). There's no way for an external consumer to
|
||||
intercept the decoded PCM of a specific remote stream — it all goes to the hardware device.
|
||||
A bot that wants to record, transcribe, or re-broadcast remote audio has no hook.
|
||||
|
||||
**Plan (API design — clean, append-only, no struct changes, ABI-stable):**
|
||||
- **`vc_stream_feed_pcm`** — promote `vc_test_inject_capture` to a public, documented API
|
||||
and add a `channels` parameter:
|
||||
```c
|
||||
/* External PCM feed — replaces the hardware capture device for this stream. Caller
|
||||
provides interleaved int16 PCM at the stream's sample rate. The core frames it,
|
||||
encodes (Opus), seals (AEAD), and sends (UDP). Works for any stream kind
|
||||
(MIC/SCREEN_AUDIO/AUX_DEVICE). The stream must be started first (vc_stream_start);
|
||||
this just replaces the capture source. channels = 1 (mono) or 2 (stereo interleaved).
|
||||
Thread-safe; may be called from any thread including audio callbacks. */
|
||||
vc_result vc_stream_feed_pcm(vc_client* c, uint32_t stream_id,
|
||||
const int16_t* pcm, size_t samples_per_channel,
|
||||
uint32_t channels);
|
||||
```
|
||||
- **`vc_set_pcm_sink`** — symmetric output side: receive decoded remote audio as int16 PCM
|
||||
instead of (or in addition to) the hardware playback device:
|
||||
```c
|
||||
/* External PCM tap — receive decoded, mixed remote audio as int16 PCM. The callback
|
||||
fires on the audio thread with the mixed output for a specific remote stream. Pass
|
||||
cb=NULL to disable (default: disabled, hardware playback only). When enabled, PCM is
|
||||
delivered to the sink AND the hardware device (dual output) so a bot can record
|
||||
without disabling local monitoring. user_id+stream_id identify the source stream.
|
||||
The callback MUST NOT block — copy what you need and return (same contract as
|
||||
vc_callbacks.on_event). */
|
||||
typedef void (*vc_pcm_sink_cb)(void* user, uint32_t user_id, uint32_t stream_id,
|
||||
const int16_t* pcm, size_t samples_per_channel,
|
||||
uint32_t channels, uint32_t sample_rate);
|
||||
vc_result vc_set_pcm_sink(vc_client* c, vc_pcm_sink_cb cb, void* user);
|
||||
```
|
||||
- **Core changes:**
|
||||
- `core/include/voicecat.h` — add `vc_pcm_sink_cb` typedef + the two function
|
||||
declarations (append-only, after `vc_test_inject_capture`). Full doc comments on both
|
||||
(contract, thread-safety, lifetime, use cases).
|
||||
- `core/src/voicecat.cpp` — thin C trampolines → `vc_client::stream_feed_pcm` /
|
||||
`set_pcm_sink`.
|
||||
- `core/src/core/client.{h,cpp}` — `stream_feed_pcm`: validates `stream_id`, looks up
|
||||
the `LocalStream`'s kind, calls `audio_engine_.inject_capture(kind, pcm, n)` (existing
|
||||
path) with the channel count forwarded. `set_pcm_sink`: stores the callback + user
|
||||
pointer; `on_playback` (or a new fan-out in the mixer) invokes it per remote stream
|
||||
alongside the existing hardware write. Keep `vc_test_inject_capture` as a deprecated
|
||||
alias calling `stream_feed_pcm(..., channels=1)` for source compatibility.
|
||||
- `core/src/audio/audio_engine.{h,cpp}` — `inject_capture` already exists per-kind; add
|
||||
a `channels` parameter to the ring-buffer write path (or a parallel stereo-aware
|
||||
variant). The encode path in `client.cpp::on_capture_frame` already handles
|
||||
`channels==2` via the stereo encode branch — just plumb the value through. For the
|
||||
sink: add a `pcm_sink_` member (callback + user); in `on_playback` after mixing, if the
|
||||
sink is set, copy the mixed PCM for the current stream and invoke the callback. The
|
||||
copy must stay off the RT-critical path — document the non-blocking contract.
|
||||
- **Skeleton stub path:** update `client.cpp`'s `#else` (no-deps) stub section to add
|
||||
`vc_stream_feed_pcm`/`vc_set_pcm_sink` returning `VC_ERR_NOT_IMPLEMENTED` — keeps the
|
||||
skeleton preset green.
|
||||
- **Swift `VoiceCatCore`:** add `feedPcm(streamId:pcm:samplesPerChannel:channels:)` and
|
||||
`setPcmSink(_:user:)` (the Swift wrapper around `vc_pcm_sink_cb` — a
|
||||
`@convention(c)` closure + `Unmanaged` context, mirroring `Callbacks.swift`). Wraps both
|
||||
new ABI functions.
|
||||
- **C# `VoiceCat.Interop`:** add `StreamFeedPcm(streamId, pcm, samples, channels)` (with
|
||||
`int16[]` marshaling) and `SetPcmSink` (delegates via `[UnmanagedCallersOnly]` thunk,
|
||||
mirroring the event-callback pattern). Wraps both new ABI functions.
|
||||
- **Tests:**
|
||||
- `tests/test_external_pcm.cpp` (new) — `test_feed_pcm_round_trip`: two clients, A feeds
|
||||
a known mono sine wave via `vc_stream_feed_pcm` on a MIC stream, B receives via the
|
||||
normal decode path and asserts energy matches. `test_feed_pcm_stereo`: same with
|
||||
`channels=2`, assert L≠R end-to-end (mirrors the WASAPI loopback stereo test).
|
||||
`test_pcm_sink`: B sets a `vc_pcm_sink_cb`, A feeds PCM, assert the sink callback
|
||||
receives the decoded PCM with matching energy. All headless, no audio hardware.
|
||||
- `clients/apple/Tests/VoiceCatCoreTests/` — Swift wrapper round-trip for `feedPcm`.
|
||||
- `clients/windows/VoiceCat.Interop.Tests/` — C# wrapper round-trip.
|
||||
- **Docs:**
|
||||
- `docs/architecture.md §4` — new subsection on external PCM feed/tap: the contract
|
||||
(caller provides interleaved int16 at the stream's sample rate; core frames/encodes/
|
||||
seals/sends for feed; core decodes/mixes/delivers for sink; sink callback must not
|
||||
block), the use cases (ReplayKit, ScreenCaptureKit, bots, custom clients), and the
|
||||
relationship to `vc_test_inject_capture` (deprecated alias).
|
||||
- `docs/voice.md §9` — update the iOS ReplayKit and macOS ScreenCaptureKit rows: both
|
||||
now consume `vc_stream_feed_pcm` instead of a "minimal slice of the core." Update the
|
||||
iOS detail bullets: the extension links Opus + `vc_stream_feed_pcm` (not a parallel
|
||||
media stack). Add a macOS ScreenCaptureKit note: convert `CMSampleBuffer` → int16,
|
||||
feed via `vc_stream_feed_pcm` — this is how macOS screen-audio actually ships.
|
||||
- `docs/protocol.md` — no protocol changes (the feed/sink are client-local; the wire
|
||||
format is identical whether PCM came from miniaudio or an external source). Note this
|
||||
explicitly.
|
||||
- `docs/roadmap.md` — add a milestone entry; update the iOS ReplayKit and macOS
|
||||
ScreenCaptureKit pending items to reference `vc_stream_feed_pcm`.
|
||||
- **Implementation order:**
|
||||
1. C ABI + core (`voicecat.h`, `voicecat.cpp`, `client.{h,cpp}`, `audio_engine.{h,cpp}`) +
|
||||
skeleton stub. Verify `ctest --preset dev` green.
|
||||
2. `tests/test_external_pcm.cpp` — the three behavior tests. Verify green.
|
||||
3. Swift `VoiceCatCore` wrapper + `VoiceCatCoreTests` round-trip.
|
||||
4. C# `VoiceCat.Interop` wrapper + `VoiceCatClientSmokeTests` round-trip.
|
||||
5. Docs (`architecture.md`, `voice.md`, `protocol.md`, `roadmap.md`, header comments).
|
||||
6. **Then** ReplayKit (iOS) and ScreenCaptureKit (macOS) become ~100-line consumers of
|
||||
this API instead of parallel media stacks.
|
||||
- **Verification:** `ctest --preset dev` green (3 new tests); `swift test` green; `dotnet
|
||||
test` green; `xcodebuild` (skeleton) green. The feed/sink tests are fully headless — no
|
||||
audio hardware, no simulator, no device — so they run in CI on every platform.
|
||||
- **Files to touch:**
|
||||
- Core C++: `core/include/voicecat.h`, `core/src/voicecat.cpp`,
|
||||
`core/src/core/client.{h,cpp}`, `core/src/audio/audio_engine.{h,cpp}`.
|
||||
- Tests: `tests/test_external_pcm.cpp` (new), `tests/CMakeLists.txt`.
|
||||
- Swift: `clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift`,
|
||||
`clients/apple/Sources/VoiceCatCore/Callbacks.swift`,
|
||||
`clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift` (new).
|
||||
- C#: `clients/windows/VoiceCat.Interop/VoiceCatClient.cs`,
|
||||
`clients/windows/VoiceCat.Interop/NativeMethods.cs`,
|
||||
`clients/windows/VoiceCat.Interop.Tests/ExternalPcmTests.cs` (new).
|
||||
- Docs: `docs/architecture.md`, `docs/voice.md`, `docs/protocol.md`, `docs/roadmap.md`.
|
||||
- **ABI stability:** append-only — two new functions + one new typedef, no existing
|
||||
structs/enums changed. `vc_test_inject_capture` stays as a deprecated alias for source
|
||||
compatibility. Treat as a deliberate, versioned ABI event per `docs/protocol.md §8`.
|
||||
- **Relationship to the iOS audio routing plan:** orthogonal. The iOS routing layer controls
|
||||
*which hardware route* miniaudio opens (AVAudioSession config in Swift). This plan is about
|
||||
*bypassing miniaudio's hardware entirely* (external PCM feed/tap). Both ship; they don't
|
||||
conflict. ReplayKit/ScreenCaptureKit consume this API; the iOS routing layer controls the
|
||||
*mic* path which still uses miniaudio's device.
|
||||
|
||||
---
|
||||
|
||||
## Recent completed work
|
||||
|
||||
All items below are `[x]` done; `ctest --preset dev` 23/23 on Windows after all.
|
||||
|
||||
- **External PCM feed/tap API** (2026-06-20): `vc_stream_feed_pcm` + `vc_set_pcm_sink` shipped.
|
||||
Promotes `vc_test_inject_capture` (mono-only, TEST-ONLY) to a public, stereo-capable API.
|
||||
Adds symmetric PCM sink on the playback thread. Swift wrapper (`feedPcm`/`setPcmSink` in
|
||||
`VoiceCatClient.swift`, 4 XCTest smoke tests). C# wrapper (`StreamFeedPcm`/`SetPcmSink` in
|
||||
`VoiceCatClient.cs` + `NativeMethods.cs`, 4 xUnit smoke tests in `ExternalPcmTests.cs`).
|
||||
Three new headless C++ ctests. Docs: architecture.md §4 new subsection, voice.md §9 updated,
|
||||
protocol.md §8 explicit no-protocol-change note, roadmap.md M5 entry. Files: `voicecat.h`,
|
||||
`voicecat.cpp`, `client.{h,cpp}`, `audio_engine.{h,cpp}`, `tests/test_external_pcm.cpp`,
|
||||
`tests/CMakeLists.txt`, Swift + C# wrappers.
|
||||
|
||||
- **iOS A2DP + stereo root cause fix** (2026-06-20): miniaudio's NULL-context `ma_device_init`
|
||||
was calling `AVAudioSession setCategory(Record)` on every device open, wiping the session
|
||||
config `IOSAudioRouter` had set. Fixed by sharing a `ma_context` with
|
||||
`sessionCategory=none` + `noAudioSessionActivate/Deactivate=MA_TRUE` — miniaudio never
|
||||
touches `AVAudioSession`; `IOSAudioRouter` is the sole owner. Files: `audio_engine.{h,cpp}`.
|
||||
|
||||
- **iOS audio routing overhaul** (2026-06-19): Full `IOSAudioRouter` singleton drives all
|
||||
`AVAudioSession` config before miniaudio opens devices. Fixed stereo mic polar-pattern setup
|
||||
(WWDC20 recipe: `setPreferredInput` + `setInputDataSource` + `.stereo` polar pattern + no
|
||||
`setPreferredInputNumberOfChannels`). Added `vc_audio_restart` ABI (full stop+reinit for
|
||||
close→reconfigure→reopen ordering). Added `vc_set_capture_channels` ABI (core stereo-mic
|
||||
support). AVAudioSession activated proactively on `.authResult`, not lazily on
|
||||
`.streamStarted`. Join/Leave Voice button added (parity with macOS). Channel-id sync fixed
|
||||
(mic button was permanently dimmed). iOS deployment target raised to 18.0.
|
||||
|
||||
- **iOS SwiftUI client** (2026-06-19): `VoiceCatiOS.xcodeproj` at `clients/apple/iOS/`.
|
||||
Full feature parity with macOS/Windows: saved server list (JSON + Keychain, App Group
|
||||
`group.cat.voice.VoiceCat`), TOFU, connect flow, channel tree, user list with context menus,
|
||||
chat, admin sheets, voice controls, settings. `xcodebuild` → BUILD SUCCEEDED.
|
||||
|
||||
- **macOS AppKit client** (2026-06-18): `VoiceCatMac.xcodeproj` at `clients/apple/macOS/`.
|
||||
Fixed compile errors (`NSAccessibility` call-site arg order, `StreamSummary.id` vs
|
||||
`.streamId`) and linker issues (`OTHER_LDFLAGS = -lc++`, `ONLY_ACTIVE_ARCH = YES` for
|
||||
Release). Debug + Release both BUILD SUCCEEDED.
|
||||
|
||||
- **Swift `VoiceCatCore` package + XCFramework** (2026-06-18): Shared Swift wrapper at
|
||||
`clients/apple/`. `build-xcframework.sh` merges `libvoicecat.a` + 107 vcpkg static deps into
|
||||
a fat `.a` via `libtool -static`. 6/6 Swift tests green (real server, mirrors C# Interop
|
||||
tests). Supports macOS-arm64 + iOS-arm64 + iOS-sim slices.
|
||||
|
||||
- **macOS port validated** (2026-06-18): 21/21 on macOS. Three cross-platform bugs fixed:
|
||||
missing `<netdb.h>` in POSIX test branch; SIGPIPE kills (added `SIG_IGN`); use-after-free of
|
||||
Asio kqueue reactor on server shutdown (fixed `TcpAcceptor` shutdown/connection-drain
|
||||
sequence).
|
||||
|
||||
- **CMake preset cleanup** (2026-06-18): `m1-dev`→`dev`, `dev`→`skeleton`, `m2-dev` dropped.
|
||||
New `release`, `server-release` (stripped), `apple-dev`/`apple-ios`/`apple-ios-sim`. Cross-
|
||||
platform triplet auto-resolved by `cmake/voicecat-toolchain.cmake`.
|
||||
|
||||
- **Disconnect, keepalive & reaper** (2026-06-18): Client sends `Ping` every 15 s; server
|
||||
reaper drops sessions after 45 s; UDP `KEEPALIVE` every 5 s keeps NAT alive. `vc_disconnect`
|
||||
sends graceful `Disconnect` proto. Stale-user LEFT broadcast on drop. PLC capped at ~2 s.
|
||||
Three new tests: `test_disconnect_left`, `test_plc_cap`, `test_reaper_timeout`.
|
||||
|
||||
- **Stereo screen-audio loopback** (2026-06-17): WASAPI loopback opens in channel's
|
||||
stereo/mono mode (was hardcoded mono). Real stereo flows end-to-end through loopback → encode
|
||||
→ decode → mixer. New `test_loopback_stereo_capture`.
|
||||
|
||||
- **Windows screen-audio UI wired** (2026-06-17): `btnScreenShareToggle` in `MainForm.cs`.
|
||||
No core/proto/ABI changes — all the plumbing was already there. `dotnet test` 4/4 green.
|
||||
|
||||
- **Bug fixes** (2026-06-16 – 2026-06-17):
|
||||
- *AEAD nonce desync in SFU relay* — relay forwarded sender's `seq` verbatim; recipient
|
||||
nonce reconstruction used the wrong counter. Fixed by rewriting the outgoing `seq` field
|
||||
to the recipient's `peek_send_counter()`.
|
||||
- *Playout clock free-ran* — `playout_ts` advanced even during VAD/PTT silence gaps,
|
||||
eventually dropping all frames as too-late. Fixed with resync in `on_playback` via
|
||||
`JitterBuffer::peek_front_ts()`.
|
||||
- *Stale users after disconnect* — `ConnSession::close()` didn't broadcast `UserEvent::LEFT`
|
||||
before erasing. Fixed; PLC cap added as defense-in-depth.
|
||||
- *"Randomly bumped to Lobby"* — server excluded the actor from its own state-change
|
||||
broadcasts. Fixed: `UserEvent::UPDATED` now goes to all clients including the actor.
|
||||
- *Silent playback after join* — `opus_decode` received hardware callback frame count as
|
||||
`max_samples` instead of the Opus frame size. Fixed with a decode ring buffer.
|
||||
|
||||
---
|
||||
|
||||
## Milestones (see [docs/roadmap.md](docs/roadmap.md) for full detail)
|
||||
|
||||
- [x] **M0 — Scaffolding** ✓ complete
|
||||
- [x] **M1 — Control plane** ✓ complete (2026-06-15)
|
||||
- [x] **M2 — Voice, single stream** ✓ complete (2026-06-16)
|
||||
- [x] **M3 — Multi-stream & per-channel tuning** ✓ complete (2026-06-16)
|
||||
- [x] **M4 — Native clients** — Windows WinForms ✓ (2026-06-17); macOS AppKit ✓ (2026-06-18); iOS SwiftUI ✓ (2026-06-19)
|
||||
- [~] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …)
|
||||
|
||||
---
|
||||
|
||||
## M0 — Scaffolding ✓
|
||||
|
||||
Repo layout (`core/ server/ tools/ clients/ tests/`), CMake + vcpkg manifest, C ABI header
|
||||
(`voicecat.h`), proto source of truth, core stubs for all six subsystems, `voicecat-server` +
|
||||
`vccli` skeletons, smoke CTest, `.clang-format`/`.gitattributes`/`.gitignore`.
|
||||
|
||||
---
|
||||
|
||||
## M1 — Control plane ✓ (completed 2026-06-15)
|
||||
|
||||
**Exit criterion:** `test_m1_integration` — two clients authenticate over TLS 1.3 (guest +
|
||||
Argon2id), exchange channel + private text. ~1 s.
|
||||
|
||||
`FrameCodec`, `TlsContext` (mbedTLS 1.3, ECDSA-P256 self-signed, TOFU pins TLS leaf-cert
|
||||
SHA-256), `WorkerPool`, `Database` (SQLite + Argon2id), `ServerIdentityManager`,
|
||||
`ConnSession` state machine, `SessionRegistry`, `vc_client` full M1 C ABI, `voicecat-admin`
|
||||
CLI, dual-stack `TcpAcceptor`. **Key bug fixed:** `send_frame` double-framing — `encode_envelope`
|
||||
was pre-framing the protobuf; fixed by passing raw protobuf bytes.
|
||||
|
||||
---
|
||||
|
||||
## M2 — Voice, single stream ✓ (completed 2026-06-16)
|
||||
|
||||
**Exit criterion:** `test_m2_voice` + `test_voice_client_abi` — two headless clients auth, bind
|
||||
UDP, 50 Opus frames relayed + re-encrypted by SFU, B receives ≥25 and decrypts. ~4 s.
|
||||
|
||||
14-byte UDP voice header, `SodiumMediaCrypto` (ChaCha20-Poly1305 + 64-bit anti-replay),
|
||||
`OpusEncoder`/`OpusDecoder` (FEC, PLC), `UdpMediaChannel`, `JitterBuffer`, `AudioEngine`
|
||||
(miniaudio), `MediaRelay` SFU. **Key bug fixed:** `on_playback` passed hardware callback frame
|
||||
count as `opus_decode` max_samples; fixed with a per-stream decode ring buffer.
|
||||
|
||||
---
|
||||
|
||||
## M3 — Multi-stream & per-channel tuning ✓ (completed 2026-06-16)
|
||||
|
||||
**Exit criterion:** `test_m3_multistream` — client A runs two concurrent streams (MIC +
|
||||
SCREEN_AUDIO); B sees both; per-stream gain/mute/NR independent; effective Opus config matches
|
||||
channel's server-enforced settings. ~2.4 s.
|
||||
|
||||
Fixed server `stream_id` counter bug (always wrote `1`). Per-channel `AudioConfig` populated
|
||||
(Lobby: mono/24kbps/VOIP + DTX; Music Room: stereo/128kbps/AUDIO). `LocalStream` map,
|
||||
`pending_announce_kind_`, `run_talk_timer()`, thread-join race in `teardown_voice()` fixed.
|
||||
New C ABI: `vc_get_stream_audio_config`, `vc_test_inject_capture`.
|
||||
|
||||
---
|
||||
|
||||
## Post-M3 follow-up ✓ (completed 2026-06-16)
|
||||
|
||||
- **Device enumeration** — `vc_list_devices`/`vc_set_input_device`; opaque hex device ids;
|
||||
`vc_free_device_list` now frees. Works pre-connect.
|
||||
- **VAD/PTT gate** — `EnergyVadProcessor` (RMS threshold ~0.025, 300 ms hang-time);
|
||||
`vc_set_input_mode`/`vc_set_push_to_talk`; MIC-only (SCREEN_AUDIO/AUX_DEVICE bypass).
|
||||
- **True stereo playback** — `playback_channels=2`; stereo decoded L→L R→R in mixer; mono
|
||||
upmixed L=R; hardware fallback to mono on failure.
|
||||
- **WASAPI loopback** — `loopback_device_` with `ma_device_type_loopback`;
|
||||
`VOICECAT_HAS_LOOPBACK` macro (Windows-only). `vccli --share-screen-audio`.
|
||||
|
||||
**Known deferred (still open):** AEC/NS/AGC (no working Windows/MSVC WebRTC APM build);
|
||||
process-specific WASAPI loopback; RT-thread rule violation in `on_capture_frame` (mutex lock
|
||||
on audio callback thread — pre-existing, needs lock-free ring-buffer refactor).
|
||||
|
||||
---
|
||||
|
||||
## M4 — Native clients ✓ (completed 2026-06-17 – 2026-06-19)
|
||||
|
||||
**Exit criterion:** `ctest --preset dev` 21/21 green; `dotnet build` 0 warnings; `xcodebuild`
|
||||
BUILD SUCCEEDED (macOS + iOS); manually verified: connect, TOFU, channel tree, join, voice,
|
||||
text, device pickers, level meter on each platform.
|
||||
|
||||
**New C ABI (additive):** `vc_list_channels`/`vc_list_users`/`vc_list_user_streams`,
|
||||
`vc_join_channel`, `VC_EVENT_SERVER_IDENTITY` + `vc_confirm_server_identity`,
|
||||
`vc_config::tofu_store_path`, `VC_INPUT_ALWAYS_ON`, `vc_set_vad_threshold`,
|
||||
`vc_audio_suspend`/`vc_audio_resume`, `vc_audio_restart`, `vc_set_capture_channels`.
|
||||
|
||||
**Windows** (`clients/windows/`): `VoiceCat.Interop` (P/Invoke, `[UnmanagedCallersOnly]`),
|
||||
`VoiceCat.App` (ConnectDialog, ServerIdentityDialog, MainForm with full M5 moderation UI,
|
||||
PerUserTuningDialog, PttKeyCaptureDialog), `VoiceCat.Interop.Tests`. PTT is focus-scoped.
|
||||
|
||||
**macOS** (`clients/apple/macOS/VoiceCatMac.xcodeproj`): NSOutlineView channel tree,
|
||||
NSTableView user list, NSTextView chat, voice controls, full VoiceOver accessibility, admin
|
||||
menu, 17 Swift source files. `build-xcframework.sh` produces `VoiceCatCore.xcframework`.
|
||||
|
||||
**iOS** (`clients/apple/iOS/VoiceCatiOS.xcodeproj`): SwiftUI, `NavigationSplitView`/`TabView`,
|
||||
`OutlineGroup` channel tree, `IOSAudioRouter` AVAudioSession driver, 24 Swift source files,
|
||||
iOS 18.0 deployment target. App Group `group.cat.voice.VoiceCat` for Keychain sharing.
|
||||
|
||||
---
|
||||
|
||||
## M5 — Moderation, polish, and beyond [~] (in progress 2026-06-17)
|
||||
|
||||
**Exit criterion:** four ABI-level tests green (`test_m5_permissions`,
|
||||
`test_m5_kick_ban_move_mute`, `test_m5_admin_accounts`, `test_m5_channel_crud`);
|
||||
`vccli` can drive all moderation/admin/channel operations against a live server.
|
||||
|
||||
- [x] **Server-side moderation & permissions** — per-session `Permissions`, kick/ban/move/
|
||||
server-mute, channel CRUD, DB schema v2 (`channels`, `bans`), BLAKE2b channel passwords.
|
||||
- [x] **C ABI** — `vc_kick_user`, `vc_ban_user`, `vc_set_permission`, `vc_set_server_mute`,
|
||||
`vc_move_user`, `vc_create_channel`, `vc_edit_channel`, `vc_delete_channel`,
|
||||
`vc_create_account`, `vc_reset_password`, `vc_delete_account`, `vc_list_accounts`,
|
||||
`vc_get_permissions`; events `VC_EVENT_GENERIC_RESULT`, `VC_EVENT_ACCOUNT_LIST`.
|
||||
- [x] **Four M5 tests** passing — `ctest --preset dev` 21/21.
|
||||
- [x] **vccli** M5 flags: `--kick`, `--ban`, `--move`, `--server-mute`/`-unmute`/`-deafen`/
|
||||
`-undeafen`, `--set-permission`, channel CRUD, account CRUD, `--username`/`--password`.
|
||||
- [x] **All three client UIs** (Windows WinForms, macOS AppKit, iOS SwiftUI) expose the full
|
||||
M5 moderation and admin surface.
|
||||
- [x] **Docs** — `docs/protocol.md`, `docs/security.md` kept in sync.
|
||||
- [x] **DRED/audio-quality polish** — done (2026-06-20). `bool dred` added to `AudioConfig`
|
||||
proto (field 11) and `vc_audio_config` C ABI. Encoder: `OPUS_SET_DRED_DURATION(2)` when
|
||||
enabled (20 ms of ML redundancy per packet). Decoder: `OpusDREDDecoder` + per-stream
|
||||
`OpusDRED` scratch pre-allocated; `JitterBuffer::try_copy_front_payload` peeks at the next
|
||||
buffered packet on every PLC step; if DRED data is present, `opus_decoder_dred_decode`
|
||||
reconstructs the lost frame — otherwise falls back to standard PLC. New test:
|
||||
`test_dred_toggle` (ctest 22/22). Files: `voicecat.proto`, `voicecat.h`,
|
||||
`opus_codec.{h,cpp}`, `audio_engine.{h,cpp}`, `client.cpp`, `session.{h,cpp}`.
|
||||
- [ ] **DRED toggle in client UIs** — expose the `dred` flag in all three channel-config UIs
|
||||
so admins can enable it per channel. Windows: `ChannelEditForm` / `vc_channel_info.audio.dred`
|
||||
checkbox. macOS AppKit: channel-edit sheet. iOS SwiftUI: channel-edit form. All three UIs
|
||||
already have full channel CRUD wired; this is an additive checkbox on the existing audio-config
|
||||
section. (Core/protocol/ABI all done — this is UI-only work.)
|
||||
- [x] **macOS ScreenCaptureKit screen-audio** — done 2026-06-21. `ScreenAudioCapture.swift`
|
||||
drives an `SCStream` (audio-only, `excludesCurrentProcessAudio`), converts Float32 →
|
||||
int16 in the channel's mono/stereo mode, and calls `vc_stream_feed_pcm`. Capture starts on
|
||||
the self `.streamStarted` event (when the effective config is known); wired into
|
||||
`MainWindowController.screenAudioClicked()`.
|
||||
- [x] **iOS ReplayKit Broadcast Extension** (`VoiceCatBroadcast`) — done 2026-06-21.
|
||||
Forward-to-host design: the extension (`SampleHandler.swift`) captures `.audioApp`,
|
||||
converts to 48 kHz int16 stereo, and writes a shared App Group SPSC ring
|
||||
(`BroadcastAudioRing.swift`); the host's `BroadcastAudioPump` owns the `SCREEN_AUDIO`
|
||||
stream and feeds via `vc_stream_feed_pcm` (single session, no creds on disk). UI is an
|
||||
`RPSystemBroadcastPickerView` in `VoiceControlsView`. (Replaced the speculative
|
||||
`BroadcastCredentials.swift` self-connecting design, now removed.)
|
||||
- [x] **External PCM feed/tap API** (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) — done
|
||||
2026-06-20. Promotes `vc_test_inject_capture` (mono-only, TEST-ONLY) to a public API with
|
||||
stereo support. Adds a symmetric PCM sink fired on the playback thread per decoded remote
|
||||
stream. Full wrappers for Swift (`feedPcm`/`setPcmSink`) and C# (`StreamFeedPcm`/
|
||||
`SetPcmSink`). Three new C++ ctests (`test_feed_pcm_round_trip`, `test_feed_pcm_stereo`,
|
||||
`test_pcm_sink`), 4 Swift XCTest smoke tests, 4 C# xUnit smoke tests. Docs updated
|
||||
(architecture.md §4 new subsection, voice.md §9 updated, protocol.md §8 explicit
|
||||
no-protocol-change note, roadmap.md M5 entry). `ctest --preset dev` 23/23.
|
||||
- [x] **macOS client UI overhaul** — done 2026-06-20. Mirrors the Windows client's UI
|
||||
overhaul (toolbar, unified log, PM windows, channel counts, output volume, keyboard
|
||||
shortcuts), adapted to Mac-native conventions:
|
||||
- **NSToolbar**: Join Voice, Share Screen Audio, Mute, Deafen (SF Symbol toggle buttons),
|
||||
and Output Volume slider (NSSlider 0–100, default 80). Voice actions + mute/deafen +
|
||||
output volume moved out of the bottom voice panel into the toolbar. Bottom panel keeps
|
||||
input-mode segmented control / VAD slider / PTT key / device picker / level meter.
|
||||
- **Unified log**: chat `NSTextView` + activity `NSTableView` collapsed into a single
|
||||
`NSTextView` — activity events in `secondaryLabelColor` (gray), chat in default color.
|
||||
Removed `activityTableView` and `activityLog` array.
|
||||
- **Private messaging**: scope dropdown removed; compose bar always sends to the current
|
||||
channel. Each PM conversation opens in its own modeless `PrivateMessageWindowController`
|
||||
(NSWindow). Incoming `.textMessage` with `.private` scope routed to the right window;
|
||||
outgoing PMs echoed by server arrive through the same path. "Send Private Message…"
|
||||
added to user context menu. "New Private Message…" (⌘⇧N) opens `UserPickerSheet`
|
||||
listing all server users.
|
||||
- **Channel counts**: outline view renders `"Name (n)"` with live user counts;
|
||||
`refreshChannelTree()` called on `.userJoined`/`.userLeft` (was missing).
|
||||
- **Voice menu** (⌘⇧V join/leave, ⌘⇧S share screen, ⌘⇧M mute, ⌘⇧D deafen) and **Messages
|
||||
menu** (⌘⇧N new PM) added to `NSApp.mainMenu` via `NSMenuItem` key equivalents with
|
||||
`[.command, .shift]` mask. Removed on `windowWillClose`. Mac-native: ⌘ not Ctrl, dispatched
|
||||
by the responder chain (no custom key monitor needed).
|
||||
- **Output volume**: `setOutputVolume(_:)` wrapper added to `VoiceCatClient.swift` (was
|
||||
missing — the C ABI + C# wrapper shipped in commit 97fa659 but the Swift wrapper was
|
||||
never added). Wired end-to-end: toolbar slider → `client.setOutputVolume(gain)`.
|
||||
- **Part A (uncompiled Swift fix)**: the external PCM feed/tap Swift wrapper (commit
|
||||
615d2a8) was never compiled — the local xcframework predating the `voicecat.h` PCM
|
||||
additions. Fixed: rebuilt xcframework (regenerated module map), fixed `UInt`→`Int` type
|
||||
mismatch in `feedPcm` (Swift imports `size_t` as `Int` not `UInt`), added
|
||||
`VoiceCatPcmSinkCallback` typealias (Swift-idiomatic alias for the C `vc_pcm_sink_cb`
|
||||
so consumers don't need to directly import `VoiceCatC`). `swift test` 10/10 green.
|
||||
- **Audio settings moved to Settings window**: the bottom voice panel (input mode, VAD
|
||||
slider, PTT key, device picker, level meter) was removed from the main window and moved
|
||||
into a new `SettingsWindowController` — a modeless window opened via the app menu's
|
||||
"Settings…" (⌘,) item. The main window is now just toolbar + channels + users + chat.
|
||||
Source-of-truth for audio settings (`selectedInputMode`, `vadThresholdValue`,
|
||||
`selectedInputDeviceId`, `pttKeyCode`) lives in `MainWindowController` so voice start can
|
||||
apply them even before the settings window has been opened; `SettingsWindowController`
|
||||
reads from and writes back to those properties and applies changes to the client
|
||||
immediately when voice is active. The level meter is forwarded from
|
||||
`MainWindowController.handleLevel` → `settingsWindowController.updateLevel(rms:)`.
|
||||
`keyCodeName` helper deduplicated (was duplicated in `PttKeyCaptureSheet.swift` +
|
||||
`MainWindowController.swift` — now shared from `MainWindowController.swift`).
|
||||
- Files: `MainWindowController.swift` (overhauled), `PrivateMessageWindowController.swift`
|
||||
(new), `UserPickerSheet.swift` (new), `SettingsWindowController.swift` (new),
|
||||
`VoiceCatClient.swift` (setOutputVolume + VoiceCatPcmSinkCallback typealias + feedPcm
|
||||
type fix), `ExternalPcmTests.swift` (use typealias), `PttKeyCaptureSheet.swift` (removed
|
||||
duplicate `keyCodeName`), `VoiceCatMac.xcodeproj/project.pbxproj` (register 3 new files).
|
||||
- **Platform-specific adaptations** (vs. Windows): `NSToolbar` instead of `ToolStrip`;
|
||||
global menu bar + `NSMenuItem` key equivalents (⌘ not Ctrl, responder-chain dispatched);
|
||||
PM windows as modeless `NSWindow`s; picker as Mac sheet; gray = `secondaryLabelColor`;
|
||||
SF Symbols for toolbar icons.
|
||||
|
||||
---
|
||||
|
||||
## Decisions log
|
||||
|
||||
All architecture/scope decisions are settled and recorded in
|
||||
[docs/roadmap.md §2 "Resolved decisions"](docs/roadmap.md) and reflected across `docs/`.
|
||||
If you make a *new* decision, record it there and link it here.
|
||||
|
||||
---
|
||||
|
||||
## How to update this file
|
||||
|
||||
1. Check off tasks as you complete them; flip a milestone to `[x]` only when its **exit
|
||||
criterion test** passes.
|
||||
2. Keep the **"Where we left off / next action"** block at the top accurate — it's the first
|
||||
thing the next agent reads.
|
||||
3. When you start a milestone, copy its task list from `docs/roadmap.md` into a section here.
|
||||
# VoiceCat status
|
||||
|
||||
Updated: 2026-09-22
|
||||
|
||||
## Current state
|
||||
|
||||
VoiceCat's supported implementation is .NET 10. The managed protocol, crypto, TLS, server,
|
||||
CLI, client state, audio engine, Windows client, macOS client, and iOS client are implemented.
|
||||
Retired implementations and compatibility projects have been removed; this tree contains only
|
||||
the supported product and its required native media boundaries.
|
||||
|
||||
The source-of-truth layout is:
|
||||
|
||||
- `proto/voicecat.proto` — wire schema.
|
||||
- `src/` — protocol, crypto, codec/DSP bindings, server, client core, audio, and CLI.
|
||||
- `tests/VoiceCat.Tests/` — managed behavior and integration tests.
|
||||
- `clients/windows/` — WinForms application over the managed core.
|
||||
- `clients/apple/` — AppKit and UIKit applications over the managed core.
|
||||
- `native/media/` and `native/rnnoise/` — required Opus/RNNoise shim and vendored RNNoise.
|
||||
- `native/apple/broadcast/` — required ReplayKit upload extension and shared ring producer.
|
||||
|
||||
The physical-device iOS voice path now supports ReplayKit fallback, stable Apple VPIO voice-chat
|
||||
capture using a paced 20 ms handoff, and true built-in stereo microphone capture. Stereo was
|
||||
verified on an iPhone 16 Pro Max with a two-channel AVAudioEngine input and distinct left/right
|
||||
samples; the managed Apple binding requires native use of its otherwise-unmapped stereo polar
|
||||
pattern constant.
|
||||
|
||||
The iOS user list now opens a remote-user detail view with independent tuning for each active
|
||||
audio stream. Private messages are grouped into per-user conversations with direct access to the
|
||||
same user and audio controls.
|
||||
|
||||
SQLite schema v4 persists DRED and the channel packet-loss mode. Manual loss remains the default;
|
||||
automatic Fast/Balanced/Stable modes measure each sender's authenticated UDP uplink at the server,
|
||||
cap the applied Opus hint at 30%, and feed it back over TLS.
|
||||
|
||||
## Release gates
|
||||
|
||||
- Run real multi-person calls on Windows, macOS, and physical iOS hardware, including adaptive
|
||||
20/40/60 ms buffering, duration-aware DRED/FEC, automatic packet-loss feedback, and mismatched
|
||||
input/output endpoints.
|
||||
- Complete NVDA and VoiceOver navigation/announcement passes.
|
||||
- Verify iOS remote-user tuning and private-conversation navigation with VoiceOver, including
|
||||
multiple streams, users without active streams, and users who disconnect while a view is open.
|
||||
- Exercise iOS background/lock, interruption, Bluetooth, route-change, ReplayKit, and iOS 27
|
||||
ScreenCaptureKit paths on devices. The background/lock gate keeps a call active for 15+ minutes
|
||||
backgrounded and screen-locked with no periodic glitches and flat `VC_AUDIO` `feedDrops`/`starved`
|
||||
counters (the render callback now paces the mix and the 20 ms capture handoff, and a watchdog
|
||||
rebuilds a graph that stops calling back). Take a Siri or phone-call interruption while
|
||||
backgrounded and confirm audio resumes without foregrounding. Complete a
|
||||
30-minute iOS call and Wi-Fi/cellular switching with voice restoration, plus extended
|
||||
mono/stereo/voice-chat switching while joined. Verify Windows desktop/per-app stereo sharing.
|
||||
- Complete Developer ID signing/notarization. The iOS host and ReplayKit extension have been
|
||||
distribution-signed and packaged locally; upload the IPA for Apple's server-side validation.
|
||||
- Run the published Linux container and a 30-minute-or-longer server soak.
|
||||
|
||||
## Working rule
|
||||
|
||||
Keep this file short. It records only current state and open release gates. Git history is the
|
||||
implementation diary.
|
||||
|
||||
@@ -1,65 +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/). M1–M5 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:
|
||||
git clone https://github.com/microsoft/vcpkg && ./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||
export VCPKG_ROOT=/path/to/vcpkg # Linux/macOS; or $env:VCPKG_ROOT on PowerShell
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<Project Path="src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
<Project Path="src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
|
||||
<Project Path="src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
|
||||
<Project Path="src/VoiceCat.Server/VoiceCat.Server.csproj" />
|
||||
<Project Path="src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
<Project Path="src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
|
||||
<Project Path="src/VoiceCat.Cli/VoiceCat.Cli.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/VoiceCat.Tests/VoiceCat.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/clients/">
|
||||
<Project Path="clients/windows/VoiceCat.Windows/VoiceCat.Windows.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,64 +0,0 @@
|
||||
// swift-tools-version: 6.0
|
||||
//
|
||||
// VoiceCatCore — the shared Swift core for the VoiceCat macOS (AppKit) and iOS (SwiftUI)
|
||||
// clients. It wraps libvoicecat's C ABI (core/include/voicecat.h) as imported through the
|
||||
// VoiceCatCore.xcframework binary target's module map (`import VoiceCatC`), and exposes a
|
||||
// Swift-idiomatic, @MainActor-safe surface.
|
||||
//
|
||||
// Architecture: docs/architecture.md §4 ("one core, many faces"). The Windows C# client
|
||||
// (clients/windows/VoiceCat.Interop) is the proven mirror of this same layering — the Swift
|
||||
// wrapper follows the same patterns (callback-lifetime, string-lifetime, event-delivery
|
||||
// thread handoff, immediate vc_free_* on list reads) adapted to Swift's interop model.
|
||||
//
|
||||
// The XCFramework is a LOCAL BUILD ARTIFACT — run `scripts/build-xcframework.sh` before
|
||||
// `swift build` / `swift test`. See clients/apple/README.md.
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "VoiceCatCore",
|
||||
// macOS 14 (Sonoma) is the AppKit client's deployment target. iOS 18 is the SwiftUI client
|
||||
// target (clients/apple/iOS/) — 18.0 unlocks the newest AVAudioSession APIs (stereo capture,
|
||||
// polar patterns, data sources). Run `scripts/build-xcframework.sh --all` to produce all
|
||||
// three slices: macos-arm64, ios-arm64, ios-arm64-simulator.
|
||||
// swift-tools-version 6.0 is required for .iOS(.v18); swiftLanguageVersions .v5 keeps the
|
||||
// Swift 5 language mode (avoids Swift 6 strict concurrency checking on pre-existing code).
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
.iOS(.v18),
|
||||
],
|
||||
products: [
|
||||
.library(name: "VoiceCatCore", targets: ["VoiceCatCore"]),
|
||||
],
|
||||
targets: [
|
||||
// Binary target — the prebuilt static lib + headers + module map. Produced by
|
||||
// scripts/build-xcframework.sh from the `apple-dev` CMake preset.
|
||||
.binaryTarget(
|
||||
name: "VoiceCatCoreXCF",
|
||||
path: "VoiceCatCore.xcframework"
|
||||
),
|
||||
// The Swift wrapper library — what the macOS/iOS apps import as `import VoiceCatCore`.
|
||||
.target(
|
||||
name: "VoiceCatCore",
|
||||
dependencies: ["VoiceCatCoreXCF"],
|
||||
path: "Sources/VoiceCatCore"
|
||||
),
|
||||
// Smoke tests against a real voicecat-server — mirrors clients/windows/
|
||||
// VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs. Requires the `dev` CMake preset
|
||||
// to be built (build/dev/bin/voicecat-server + voicecat-admin).
|
||||
//
|
||||
// linkerSettings: libvoicecat.a is a static C++20 library (built by the apple-dev
|
||||
// preset with vcpkg's clang), so the final executable must link libc++ (the LLVM C++
|
||||
// standard library on macOS). vcpkg's static deps (mbedtls/sodium/opus/protobuf/
|
||||
// sqlite3/spdlog/asio) are already compiled into the .a; macOS system frameworks
|
||||
// (CoreAudio/CoreFoundation) are auto-discovered by the linker (PROGRESS.md).
|
||||
.testTarget(
|
||||
name: "VoiceCatCoreTests",
|
||||
dependencies: ["VoiceCatCore"],
|
||||
path: "Tests/VoiceCatCoreTests",
|
||||
linkerSettings: [
|
||||
.linkedLibrary("c++"),
|
||||
]
|
||||
),
|
||||
],
|
||||
swiftLanguageModes: [.v5]
|
||||
)
|
||||
+35
-120
@@ -1,130 +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/`.
|
||||
A Swift Package binary target can only link ONE `.a` per XCFramework slice, so
|
||||
`build-xcframework.sh` merges them all into a single self-contained `libvoicecat-fat.a`
|
||||
(~30 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)).
|
||||
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.
|
||||
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,51 +0,0 @@
|
||||
// Callbacks — the C function pointers passed to `vc_callbacks`. These are the Swift
|
||||
// equivalent of the C# client's `[UnmanagedCallersOnly]` static methods (NativeCallbacks.cs).
|
||||
//
|
||||
// The critical patterns (carried over from the proven C# implementation):
|
||||
// 1. `@convention(c)` closures — plain C function pointers, NOT GC/ARC-managed closures.
|
||||
// A @convention(c) closure cannot capture context, which is why the `user` pointer is
|
||||
// used to resolve back to the VoiceCatClient instance (the C# version uses GCHandle for
|
||||
// the same thing; Swift uses Unmanaged).
|
||||
// 2. `Unmanaged.passUnretained(self).toOpaque()` as the `user` context — a stable raw
|
||||
// pointer to the Swift object WITHOUT incrementing the retain count. This is safe
|
||||
// because `deinit` calls `vc_client_destroy` (which synchronously joins every internal
|
||||
// thread) BEFORE the object's memory is freed — so no callback can fire after the object
|
||||
// is gone. (The C# equivalent: GCHandle.Alloc + GCHandle.Free in Dispose.)
|
||||
// 3. Copy `ev.text` to a Swift `String` INSIDE `onEvent` (via `VoiceCatEvent.from(_:)`)
|
||||
// before returning — the raw pointer is dangling after the callback returns. This is
|
||||
// the #1 lifetime rule from voicecat.h's vc_event doc comment.
|
||||
|
||||
import VoiceCatC
|
||||
import Foundation
|
||||
|
||||
/// Internal: builds the `vc_callbacks` struct wired to VoiceCatClient's C function pointers.
|
||||
/// The `user` context is an Unmanaged-passUnretained pointer to the client — resolved back
|
||||
/// to the client inside `onEvent`/`onLevel` below.
|
||||
internal enum Callbacks {
|
||||
/// The `on_event` C function pointer. Non-capturing @convention(c) closure — resolves
|
||||
/// the VoiceCatClient from `user` and enqueues a safe copy of the event.
|
||||
static let onEvent: @convention(c) (
|
||||
UnsafeMutableRawPointer?, UnsafePointer<vc_event>?
|
||||
) -> Void = { user, ev in
|
||||
guard let user, let ev else { return }
|
||||
let client = Unmanaged<VoiceCatClient>.fromOpaque(user).takeUnretainedValue()
|
||||
// Copy the event (including text) to a Swift value NOW — the raw vc_event is
|
||||
// invalid after this callback returns.
|
||||
client.enqueueEvent(VoiceCatEvent.from(ev.pointee))
|
||||
}
|
||||
|
||||
/// The `on_level` C function pointer. Coalesces to "latest sample per stream_id"
|
||||
/// (intermediate values are visually irrelevant — same as C#'s ConcurrentDictionary).
|
||||
static let onLevel: @convention(c) (
|
||||
UnsafeMutableRawPointer?, UInt32, Float
|
||||
) -> Void = { user, streamId, rms in
|
||||
guard let user else { return }
|
||||
let client = Unmanaged<VoiceCatClient>.fromOpaque(user).takeUnretainedValue()
|
||||
client.enqueueLevel(streamId, rms)
|
||||
}
|
||||
|
||||
/// Construct the vc_callbacks struct for a given client.
|
||||
static func make(user: UnsafeMutableRawPointer) -> vc_callbacks {
|
||||
vc_callbacks(on_event: onEvent, on_level: onLevel, user: user)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// VoiceCatConfig — Swift-idiomatic mirror of `vc_config` (voicecat.h). Passed to
|
||||
// VoiceCatClient.init. The native CString storage for the string fields is held for the
|
||||
// client's entire lifetime inside VoiceCatClient — see VoiceCatClient.swift's doc comment
|
||||
// on why (the core stores raw pointers from vc_config by value, it does not copy the data).
|
||||
|
||||
import VoiceCatC
|
||||
|
||||
/// Configuration for a `VoiceCatClient`. Mirrors `vc_config`.
|
||||
public struct VoiceCatConfig: Sendable {
|
||||
/// E.g. "VoiceCat-macOS". Forwarded in `ClientHello.client_name`.
|
||||
public let clientName: String
|
||||
/// E.g. "0.0.1". Forwarded in `ClientHello.client_version`.
|
||||
public let clientVersion: String
|
||||
public let logLevel: VoiceCatLogLevel
|
||||
/// Path to the TOFU pin file (see `confirmServerIdentity` / docs/security.md §1.1).
|
||||
/// nil = built-in relative default (only suitable for tests).
|
||||
public let tofuStorePath: String?
|
||||
|
||||
public init(
|
||||
clientName: String,
|
||||
clientVersion: String,
|
||||
logLevel: VoiceCatLogLevel = .info,
|
||||
tofuStorePath: String? = nil
|
||||
) {
|
||||
self.clientName = clientName
|
||||
self.clientVersion = clientVersion
|
||||
self.logLevel = logLevel
|
||||
self.tofuStorePath = tofuStorePath
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// Swift-idiomatic mirrors of the voicecat.h C enums. Keep these in lockstep with
|
||||
// core/include/voicecat.h — values are append-only per the C ABI's house rule, so it's
|
||||
// safe to add new cases at the end here too, but never renumber/remove existing ones.
|
||||
//
|
||||
// Swift imports the C enums directly via `import VoiceCatC` (e.g. VoiceCatC.VC_OK), but
|
||||
// those case names are C-style (VC_ERR_NOT_IMPLEMENTED, VC_EVENT_SERVER_IDENTITY) — these
|
||||
// mirrors give the Swift UI and tests clean dot-syntax (VoiceCatResult.notImplemented,
|
||||
// VoiceCatEventType.serverIdentity) and a typed bridge to/from the C values.
|
||||
//
|
||||
// NOTE: Swift's Clang importer brings C `typedef enum` types in as UInt32-backed enums
|
||||
// (all our C enum values are non-negative), so these mirrors use UInt32 raw values too.
|
||||
// The one signed field in the ABI — `vc_event.result` is `int32_t` (not `vc_result`) — is
|
||||
// bridged via `UInt32(bitPattern:)` in Event.swift.
|
||||
|
||||
import VoiceCatC
|
||||
|
||||
/// Result codes — mirrors `vc_result` (voicecat.h). Additive-only: new values go at the end.
|
||||
public enum VoiceCatResult: UInt32, Sendable, Equatable {
|
||||
case ok = 0
|
||||
case notImplemented = 1
|
||||
case invalidArg = 2
|
||||
case notConnected = 3
|
||||
case already = 4
|
||||
case authFailed = 5
|
||||
case permissionDenied = 6
|
||||
case timeout = 7
|
||||
case io = 8
|
||||
case protocolError = 9
|
||||
case crypto = 10
|
||||
case audio = 11
|
||||
case internalError = 12
|
||||
|
||||
/// Human-readable description from the core (vc_result_string returns a static literal).
|
||||
public var description: String {
|
||||
String(cString: vc_result_string(vc_result(rawValue)))
|
||||
}
|
||||
|
||||
/// Bridge from the C enum.
|
||||
public init(_ cValue: vc_result) { self = VoiceCatResult(rawValue: cValue.rawValue) ?? .internalError }
|
||||
/// Bridge to the C enum.
|
||||
public var cValue: vc_result { vc_result(rawValue) }
|
||||
}
|
||||
|
||||
/// Log level — mirrors `vc_log_level`.
|
||||
public enum VoiceCatLogLevel: UInt32, Sendable, Equatable {
|
||||
case trace = 0
|
||||
case debug = 1
|
||||
case info = 2
|
||||
case warn = 3
|
||||
case error = 4
|
||||
case off = 5
|
||||
|
||||
public init(_ cValue: vc_log_level) { self = VoiceCatLogLevel(rawValue: cValue.rawValue) ?? .info }
|
||||
public var cValue: vc_log_level { vc_log_level(rawValue) }
|
||||
}
|
||||
|
||||
/// Connection state — mirrors `vc_connection_state`.
|
||||
public enum VoiceCatConnectionState: UInt32, Sendable, Equatable {
|
||||
case disconnected = 0
|
||||
case connecting = 1
|
||||
case tlsHandshake = 2
|
||||
case authenticating = 3
|
||||
case connected = 4
|
||||
/// M4: handshake succeeded, waiting on `confirmServerIdentity()`.
|
||||
case verifyingIdentity = 5
|
||||
|
||||
public init(_ cValue: vc_connection_state) {
|
||||
self = VoiceCatConnectionState(rawValue: cValue.rawValue) ?? .disconnected
|
||||
}
|
||||
public var cValue: vc_connection_state { vc_connection_state(rawValue) }
|
||||
}
|
||||
|
||||
/// Text message scope — mirrors `vc_text_scope`.
|
||||
public enum VoiceCatTextScope: UInt32, Sendable, Equatable {
|
||||
case channel = 0
|
||||
case `private` = 1
|
||||
case server = 2
|
||||
|
||||
public init(_ cValue: vc_text_scope) { self = VoiceCatTextScope(rawValue: cValue.rawValue) ?? .channel }
|
||||
public var cValue: vc_text_scope { vc_text_scope(rawValue) }
|
||||
}
|
||||
|
||||
/// Audio device kind — mirrors `vc_device_kind`.
|
||||
public enum VoiceCatDeviceKind: UInt32, Sendable, Equatable {
|
||||
case input = 0
|
||||
case output = 1
|
||||
|
||||
public init(_ cValue: vc_device_kind) { self = VoiceCatDeviceKind(rawValue: cValue.rawValue) ?? .input }
|
||||
public var cValue: vc_device_kind { vc_device_kind(rawValue) }
|
||||
}
|
||||
|
||||
/// Stream kind — mirrors `vc_stream_kind`.
|
||||
public enum VoiceCatStreamKind: UInt32, Sendable, Equatable {
|
||||
case mic = 0
|
||||
/// System/desktop audio (docs/voice.md §9).
|
||||
case screenAudio = 1
|
||||
case auxDevice = 2
|
||||
|
||||
public init(_ cValue: vc_stream_kind) { self = VoiceCatStreamKind(rawValue: cValue.rawValue) ?? .mic }
|
||||
public var cValue: vc_stream_kind { vc_stream_kind(rawValue) }
|
||||
}
|
||||
|
||||
/// Send-side input gate mode (docs/voice.md §11) — mirrors `vc_input_mode`.
|
||||
public enum VoiceCatInputMode: UInt32, Sendable, Equatable {
|
||||
case voiceActivation = 0
|
||||
case pushToTalk = 1
|
||||
/// Transmit unconditionally, no VAD gate.
|
||||
case alwaysOn = 2
|
||||
|
||||
public init(_ cValue: vc_input_mode) { self = VoiceCatInputMode(rawValue: cValue.rawValue) ?? .voiceActivation }
|
||||
public var cValue: vc_input_mode { vc_input_mode(rawValue) }
|
||||
}
|
||||
|
||||
/// Event type — mirrors `vc_event_type`. Additive-only.
|
||||
public enum VoiceCatEventType: UInt32, Sendable, Equatable {
|
||||
case connectionState = 0
|
||||
case authResult = 1
|
||||
case channelList = 2
|
||||
case userJoined = 3
|
||||
case userLeft = 4
|
||||
case userUpdated = 5
|
||||
case textMessage = 6
|
||||
case streamStarted = 7
|
||||
case streamStopped = 8
|
||||
case talkState = 9
|
||||
case error = 10
|
||||
case disconnected = 11
|
||||
/// M4: reply to `joinChannel()` — see `VoiceCatEvent.result` / `.channelId`.
|
||||
case joinResult = 12
|
||||
/// M4: the TOFU server-identity gate — see `VoiceCatEvent.tofuStatus` / `.text`.
|
||||
case serverIdentity = 13
|
||||
/// M5: async result for moderation/admin/channel operations.
|
||||
case genericResult = 14
|
||||
/// M5: reply to `requestAccountList()` — call `listAccounts()` to read.
|
||||
case accountList = 15
|
||||
|
||||
public init(_ cValue: vc_event_type) {
|
||||
self = VoiceCatEventType(rawValue: cValue.rawValue) ?? .error
|
||||
}
|
||||
public var cValue: vc_event_type { vc_event_type(rawValue) }
|
||||
}
|
||||
|
||||
/// TOFU server-identity classification — mirrors `vc_tofu_status`. Pins the TLS leaf
|
||||
/// certificate's SHA-256 fingerprint (NOT the display-only Ed25519 value — see
|
||||
/// docs/security.md §1.1 and `VoiceCatServerIdentity`).
|
||||
public enum VoiceCatTofuStatus: UInt32, Sendable, Equatable {
|
||||
case firstConnect = 0
|
||||
case matched = 1
|
||||
case mismatch = 2
|
||||
|
||||
public init(_ cValue: vc_tofu_status) {
|
||||
self = VoiceCatTofuStatus(rawValue: cValue.rawValue) ?? .firstConnect
|
||||
}
|
||||
public var cValue: vc_tofu_status { vc_tofu_status(rawValue) }
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// VoiceCatEvent — a Swift value type that is safe to hold/queue past the native callback's
|
||||
// return. This is the Swift analog of the C# client's `VoiceCatEvent` record.
|
||||
//
|
||||
// CRITICAL (voicecat.h's vc_event doc comment): the native `vc_event.text` pointer is owned
|
||||
// by the core and valid ONLY for the duration of the `on_event` callback. `from(_:)` copies
|
||||
// it to a Swift `String` immediately — never hold the raw `vc_event` across the callback
|
||||
// boundary, or `text` will be a dangling pointer by the time it's read. This is the #1
|
||||
// lifetime rule carried over from the Windows client (NativeCallbacks.cs / VoiceCatEvent.cs).
|
||||
|
||||
import VoiceCatC
|
||||
|
||||
/// A Swift-safe copy of a `vc_event`. Produced inside the `on_event` callback (see
|
||||
/// Callbacks.swift) — all pointer fields are converted to value types before the callback
|
||||
/// returns.
|
||||
public struct VoiceCatEvent: Sendable, Equatable {
|
||||
public let type: VoiceCatEventType
|
||||
public let connectionState: VoiceCatConnectionState
|
||||
public let result: VoiceCatResult
|
||||
public let userId: UInt32
|
||||
public let channelId: UInt32
|
||||
public let streamId: UInt32
|
||||
public let textScope: VoiceCatTextScope
|
||||
/// Generic small payload, meaning per event type. For `.serverIdentity` this is the
|
||||
/// `VoiceCatTofuStatus`; for `.genericResult` the server error code; for `.talkState`
|
||||
/// talking(0/1).
|
||||
public let u32a: UInt32
|
||||
/// Copied from the core's `vc_event.text` inside the callback. nil if the core passed NULL.
|
||||
public let text: String?
|
||||
public let timestampUnixMs: UInt64
|
||||
|
||||
/// Convenience: the TOFU status, valid when `type == .serverIdentity` (maps `u32a`).
|
||||
public var tofuStatus: VoiceCatTofuStatus? {
|
||||
type == .serverIdentity ? VoiceCatTofuStatus(rawValue: u32a) : nil
|
||||
}
|
||||
|
||||
/// Copy a native `vc_event` into a safe Swift value. MUST be called inside the callback
|
||||
/// while `ev.text` is still valid — `String(cString:)` copies the bytes here.
|
||||
@inline(__always)
|
||||
public static func from(_ ev: vc_event) -> VoiceCatEvent {
|
||||
let text: String?
|
||||
if let raw = ev.text {
|
||||
text = String(cString: raw) // copies — safe to hold past callback return
|
||||
} else {
|
||||
text = nil
|
||||
}
|
||||
// ev.result is int32_t (not vc_result) per voicecat.h — bridge via bitPattern.
|
||||
// ev.u32a is uint32_t — matches VoiceCatTofuStatus's UInt32 raw value directly.
|
||||
return VoiceCatEvent(
|
||||
type: VoiceCatEventType(ev.type),
|
||||
connectionState: VoiceCatConnectionState(ev.connection_state),
|
||||
result: VoiceCatResult(rawValue: UInt32(bitPattern: ev.result)) ?? .internalError,
|
||||
userId: ev.user_id,
|
||||
channelId: ev.channel_id,
|
||||
streamId: ev.stream_id,
|
||||
textScope: VoiceCatTextScope(ev.text_scope),
|
||||
u32a: ev.u32a,
|
||||
text: text,
|
||||
timestampUnixMs: ev.timestamp_unix_ms
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// Marshaling — shared "walk a native array of owned-struct entries, convert to Swift value
|
||||
// types, free the native list" pattern. Identical shape for vc_device_list / vc_channel_list
|
||||
// / vc_user_list / vc_stream_summary_list / vc_account_list (all core-allocated, caller-freed
|
||||
// per voicecat.h). The matching vc_free_*_list call happens INSIDE each function here,
|
||||
// immediately after the conversion, so callers never need to remember to free anything
|
||||
// themselves. This is the Swift analog of the C# client's Marshaling.cs.
|
||||
|
||||
import VoiceCatC
|
||||
import Foundation
|
||||
|
||||
/// Internal marshaling helpers — convert core-allocated C arrays to Swift arrays and
|
||||
/// immediately free the native list. Not part of the public API.
|
||||
internal enum Marshaling {
|
||||
/// Convert a nullable `const char*` to a Swift `String` (empty if NULL).
|
||||
@inline(__always)
|
||||
static func string(_ ptr: UnsafePointer<CChar>?) -> String {
|
||||
guard let ptr else { return "" }
|
||||
return String(cString: ptr)
|
||||
}
|
||||
|
||||
static func devices(_ list: inout vc_device_list) -> [Device] {
|
||||
guard let items = list.items else { vc_free_device_list(&list); return [] }
|
||||
var result: [Device] = []
|
||||
result.reserveCapacity(list.count)
|
||||
for i in 0..<list.count {
|
||||
let d = items.advanced(by: i).pointee
|
||||
result.append(Device(id: string(d.id), name: string(d.name), isDefault: d.is_default != 0))
|
||||
}
|
||||
vc_free_device_list(&list)
|
||||
return result
|
||||
}
|
||||
|
||||
static func channels(_ list: inout vc_channel_list) -> [Channel] {
|
||||
guard let items = list.items else { vc_free_channel_list(&list); return [] }
|
||||
var result: [Channel] = []
|
||||
result.reserveCapacity(list.count)
|
||||
for i in 0..<list.count {
|
||||
let c = items.advanced(by: i).pointee
|
||||
result.append(Channel(id: c.id, parentId: c.parent_id, name: string(c.name),
|
||||
topic: string(c.topic), passwordProtected: c.password_protected != 0,
|
||||
maxUsers: c.max_users))
|
||||
}
|
||||
vc_free_channel_list(&list)
|
||||
return result
|
||||
}
|
||||
|
||||
static func users(_ list: inout vc_user_list) -> [User] {
|
||||
guard let items = list.items else { vc_free_user_list(&list); return [] }
|
||||
var result: [User] = []
|
||||
result.reserveCapacity(list.count)
|
||||
for i in 0..<list.count {
|
||||
let u = items.advanced(by: i).pointee
|
||||
result.append(User(id: u.id, nickname: string(u.nickname), isGuest: u.is_guest != 0,
|
||||
channelId: u.channel_id, selfMicMuted: u.self_mic_muted != 0,
|
||||
selfDeafened: u.self_deafened != 0, serverMuted: u.server_muted != 0,
|
||||
serverDeafened: u.server_deafened != 0))
|
||||
}
|
||||
vc_free_user_list(&list)
|
||||
return result
|
||||
}
|
||||
|
||||
static func streamSummaries(_ list: inout vc_stream_summary_list) -> [StreamSummary] {
|
||||
guard let items = list.items else { vc_free_stream_summary_list(&list); return [] }
|
||||
var result: [StreamSummary] = []
|
||||
result.reserveCapacity(list.count)
|
||||
for i in 0..<list.count {
|
||||
let s = items.advanced(by: i).pointee
|
||||
result.append(StreamSummary(streamId: s.stream_id, kind: VoiceCatStreamKind(s.kind),
|
||||
label: string(s.label)))
|
||||
}
|
||||
vc_free_stream_summary_list(&list)
|
||||
return result
|
||||
}
|
||||
|
||||
static func accounts(_ list: inout vc_account_list) -> [Account] {
|
||||
guard let items = list.items else { vc_free_account_list(&list); return [] }
|
||||
var result: [Account] = []
|
||||
result.reserveCapacity(list.count)
|
||||
for i in 0..<list.count {
|
||||
let a = items.advanced(by: i).pointee
|
||||
result.append(Account(username: string(a.username), isAdmin: a.is_admin != 0,
|
||||
createdAtUnixMs: a.created_at_unix_ms,
|
||||
lastLoginUnixMs: a.last_login_unix_ms))
|
||||
}
|
||||
vc_free_account_list(&list)
|
||||
return result
|
||||
}
|
||||
|
||||
static func remoteStreamState(_ s: vc_remote_stream_state) -> RemoteStreamState {
|
||||
RemoteStreamState(gain: s.gain, muted: s.muted != 0, noiseReduction: s.noise_reduction != 0)
|
||||
}
|
||||
|
||||
static func audioConfig(_ c: vc_audio_config) -> AudioConfig {
|
||||
AudioConfig(codec: c.codec, stereo: c.mode != 0, sampleRate: c.sample_rate,
|
||||
bitrateBps: c.bitrate_bps, frameMs: c.frame_ms, application: c.application,
|
||||
fec: c.fec != 0, expectedPacketLoss: c.expected_packet_loss,
|
||||
dtx: c.dtx != 0, complexity: c.complexity)
|
||||
}
|
||||
|
||||
static func permissions(_ p: vc_permissions) -> Permissions {
|
||||
Permissions(canCreateTempChannel: p.can_create_temp_channel != 0,
|
||||
canKick: p.can_kick != 0, canBan: p.can_ban != 0,
|
||||
canMoveUsers: p.can_move_users != 0,
|
||||
canAdminAccounts: p.can_admin_accounts != 0,
|
||||
isAdmin: p.is_admin != 0)
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
// Plain Swift value types — what survives past the native struct/free-list lifetime
|
||||
// (Marshaling.swift converts the C structs into these and immediately frees the native
|
||||
// list). Nothing here holds a raw pointer. This is the Swift analog of the C# client's
|
||||
// Models.cs. Field naming follows Swift camelCase (the C structs use snake_case).
|
||||
|
||||
import VoiceCatC
|
||||
|
||||
/// Channel snapshot — mirrors `vc_channel` (the pull-based view; re-call `listChannels()`
|
||||
/// after `.channelList` / `.userJoined` / `.userLeft` / `.userUpdated` events).
|
||||
public struct Channel: Sendable, Equatable, Identifiable {
|
||||
public let id: UInt32
|
||||
/// 0 = root.
|
||||
public let parentId: UInt32
|
||||
public let name: String
|
||||
public let topic: String
|
||||
public let passwordProtected: Bool
|
||||
/// 0 = unlimited.
|
||||
public let maxUsers: UInt32
|
||||
|
||||
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
|
||||
passwordProtected: Bool, maxUsers: UInt32) {
|
||||
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
|
||||
self.passwordProtected = passwordProtected; self.maxUsers = maxUsers
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel creation/edition descriptor — mirrors `vc_channel_info`. Used by
|
||||
/// `createChannel(_:)` and `editChannel(_:)`. `id == 0` means new channel (for create).
|
||||
public struct ChannelEdit: Sendable, Equatable {
|
||||
public let id: UInt32 // 0 = new channel for create
|
||||
public let parentId: UInt32 // 0 = root
|
||||
public let name: String
|
||||
public let topic: String
|
||||
public let passwordProtected: Bool
|
||||
public let password: String? // nil/empty ignored if passwordProtected == false
|
||||
public let maxUsers: UInt32 // 0 = unlimited
|
||||
public let sortOrder: UInt32
|
||||
/// 0/nil fields use server defaults.
|
||||
public let audio: AudioConfig
|
||||
|
||||
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
|
||||
passwordProtected: Bool, password: String?, maxUsers: UInt32,
|
||||
sortOrder: UInt32, audio: AudioConfig) {
|
||||
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
|
||||
self.passwordProtected = passwordProtected; self.password = password
|
||||
self.maxUsers = maxUsers; self.sortOrder = sortOrder; self.audio = audio
|
||||
}
|
||||
}
|
||||
|
||||
/// User snapshot — mirrors `vc_user`.
|
||||
public struct User: Sendable, Equatable, Identifiable {
|
||||
public let id: UInt32
|
||||
public let nickname: String
|
||||
public let isGuest: Bool
|
||||
public let channelId: UInt32
|
||||
public let selfMicMuted: Bool
|
||||
public let selfDeafened: Bool
|
||||
public let serverMuted: Bool
|
||||
public let serverDeafened: Bool
|
||||
|
||||
public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32,
|
||||
selfMicMuted: Bool, selfDeafened: Bool, serverMuted: Bool,
|
||||
serverDeafened: Bool) {
|
||||
self.id = id; self.nickname = nickname; self.isGuest = isGuest; self.channelId = channelId
|
||||
self.selfMicMuted = selfMicMuted; self.selfDeafened = selfDeafened
|
||||
self.serverMuted = serverMuted; self.serverDeafened = serverDeafened
|
||||
}
|
||||
}
|
||||
|
||||
/// Permission bitset — mirrors `vc_permissions` (M5).
|
||||
public struct Permissions: Sendable, Equatable {
|
||||
public let canCreateTempChannel: Bool
|
||||
public let canKick: Bool
|
||||
public let canBan: Bool
|
||||
public let canMoveUsers: Bool
|
||||
public let canAdminAccounts: Bool
|
||||
public let isAdmin: Bool
|
||||
|
||||
public init(canCreateTempChannel: Bool, canKick: Bool, canBan: Bool,
|
||||
canMoveUsers: Bool, canAdminAccounts: Bool, isAdmin: Bool) {
|
||||
self.canCreateTempChannel = canCreateTempChannel; self.canKick = canKick; self.canBan = canBan
|
||||
self.canMoveUsers = canMoveUsers; self.canAdminAccounts = canAdminAccounts; self.isAdmin = isAdmin
|
||||
}
|
||||
}
|
||||
|
||||
/// Account entry — mirrors `vc_account` (M5, reply to `listAccounts()`).
|
||||
public struct Account: Sendable, Equatable {
|
||||
public let username: String
|
||||
public let isAdmin: Bool
|
||||
public let createdAtUnixMs: UInt64
|
||||
public let lastLoginUnixMs: UInt64
|
||||
|
||||
public init(username: String, isAdmin: Bool, createdAtUnixMs: UInt64,
|
||||
lastLoginUnixMs: UInt64) {
|
||||
self.username = username; self.isAdmin = isAdmin
|
||||
self.createdAtUnixMs = createdAtUnixMs; self.lastLoginUnixMs = lastLoginUnixMs
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-user stream summary — mirrors `vc_stream_summary`. For the full effective Opus
|
||||
/// config of a specific (user_id, stream_id), use `VoiceCatClient.getStreamAudioConfig`.
|
||||
public struct StreamSummary: Sendable, Equatable, Identifiable {
|
||||
public let id: UInt32 // stream_id
|
||||
public let kind: VoiceCatStreamKind
|
||||
public let label: String
|
||||
|
||||
public init(streamId: UInt32, kind: VoiceCatStreamKind, label: String) {
|
||||
self.id = streamId; self.kind = kind; self.label = label
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive-side state the local listener chose for a specific remote stream — mirrors
|
||||
/// `vc_remote_stream_state`. All LOCAL (no protocol traffic) — docs/voice.md §10.
|
||||
/// Defaults (if `setRemoteStream` was never called): gain 1.0, unmuted, NR off.
|
||||
public struct RemoteStreamState: Sendable, Equatable {
|
||||
public let gain: Float // 0.0–… ; default 1.0
|
||||
public let muted: Bool
|
||||
public let noiseReduction: Bool
|
||||
|
||||
public init(gain: Float, muted: Bool, noiseReduction: Bool) {
|
||||
self.gain = gain; self.muted = muted; self.noiseReduction = noiseReduction
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio device — mirrors `vc_device`. `id` is an opaque, internally-encoded handle
|
||||
/// (currently hex-encoded `ma_device_id`) — always round-trip an id from `listDevices`;
|
||||
/// never construct one by hand (docs/architecture.md §4).
|
||||
public struct Device: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let isDefault: Bool
|
||||
|
||||
public init(id: String, name: String, isDefault: Bool) {
|
||||
self.id = id; self.name = name; self.isDefault = isDefault
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS audio input port — derived from `AVAudioSession.availableInputs`. Unlike the
|
||||
/// miniaudio-based `Device` (which returns ~2 entries on iOS), this exposes the real
|
||||
/// AVAudioSession input ports (builtInMic, bluetoothHFP, headsetMic, usbAudio, airPlay)
|
||||
/// with their data sources (orientation: front/back/top/bottom) and polar patterns
|
||||
/// (omni/cardioid/subcardioid/bidirectional). Used by `IOSAudioRouter` + `SettingsView`.
|
||||
public struct IOSAudioInputPort: Identifiable, Hashable {
|
||||
public let id: String // port UID (stable across route changes)
|
||||
public let name: String // human-readable port name
|
||||
public let portType: String // AVAudioSession.Port raw value as string
|
||||
public let dataSources: [IOSAudioDataSource]?
|
||||
public let isSelected: Bool // true if this is the current preferredInput
|
||||
|
||||
public init(id: String, name: String, portType: String,
|
||||
dataSources: [IOSAudioDataSource]?, isSelected: Bool) {
|
||||
self.id = id; self.name = name; self.portType = portType
|
||||
self.dataSources = dataSources; self.isSelected = isSelected
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS audio data source — a sub-selection of an input port (e.g. built-in mic
|
||||
/// orientation: front/back/top/bottom). May have polar pattern options.
|
||||
public struct IOSAudioDataSource: Identifiable, Hashable {
|
||||
public let id: String // dataSource UID
|
||||
public let name: String // "Front", "Back", "Top", "Bottom"
|
||||
public let polarPatterns: [String]? // AVAudioSession.PolarPattern raw values
|
||||
public let isSelected: Bool // true if this is the current preferredDataSource
|
||||
public let selectedPolarPattern: String?
|
||||
|
||||
public init(id: String, name: String, polarPatterns: [String]?,
|
||||
isSelected: Bool, selectedPolarPattern: String?) {
|
||||
self.id = id; self.name = name; self.polarPatterns = polarPatterns
|
||||
self.isSelected = isSelected; self.selectedPolarPattern = selectedPolarPattern
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS audio output route — read-only display of `AVAudioSession.currentRoute.outputs`.
|
||||
public struct IOSAudioOutputRoute: Identifiable, Hashable {
|
||||
public let id: String // port UID
|
||||
public let name: String // human-readable route name
|
||||
public let portType: String // AVAudioSession.Port raw value as string
|
||||
|
||||
public init(id: String, name: String, portType: String) {
|
||||
self.id = id; self.name = name; self.portType = portType
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective Opus configuration — mirrors `vc_audio_config`.
|
||||
public struct AudioConfig: Sendable, Equatable {
|
||||
public let codec: UInt32 // 0 = OPUS
|
||||
public let stereo: Bool // mode: 0 = mono, 1 = stereo
|
||||
public let sampleRate: UInt32
|
||||
public let bitrateBps: UInt32
|
||||
public let frameMs: UInt32
|
||||
public let application: UInt32 // 0 = VOIP, 1 = AUDIO, 2 = LOWDELAY
|
||||
public let fec: Bool
|
||||
public let expectedPacketLoss: UInt32 // % 0..100
|
||||
public let dtx: Bool
|
||||
public let complexity: UInt32 // 0..10
|
||||
|
||||
public init(codec: UInt32 = 0, stereo: Bool = false, sampleRate: UInt32 = 48000,
|
||||
bitrateBps: UInt32 = 64000, frameMs: UInt32 = 20, application: UInt32 = 0,
|
||||
fec: Bool = true, expectedPacketLoss: UInt32 = 5, dtx: Bool = false,
|
||||
complexity: UInt32 = 10) {
|
||||
self.codec = codec; self.stereo = stereo; self.sampleRate = sampleRate
|
||||
self.bitrateBps = bitrateBps; self.frameMs = frameMs; self.application = application
|
||||
self.fec = fec; self.expectedPacketLoss = expectedPacketLoss; self.dtx = dtx
|
||||
self.complexity = complexity
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream descriptor — mirrors `vc_stream_desc`. Used by `startStream(kind:deviceId:label:)`.
|
||||
public struct StreamDescriptor: Sendable, Equatable {
|
||||
public let kind: VoiceCatStreamKind
|
||||
/// nil = default device for this kind.
|
||||
public let deviceId: String?
|
||||
public let label: String
|
||||
|
||||
public init(kind: VoiceCatStreamKind, deviceId: String? = nil, label: String) {
|
||||
self.kind = kind; self.deviceId = deviceId; self.label = label
|
||||
}
|
||||
}
|
||||
|
||||
/// Server identity info — parsed from a `.serverIdentity` event + `getServerIdentityDisplay()`.
|
||||
/// The `tlsCertFingerprint` (SHA-256 hex of the TLS leaf cert) is the value the TOFU gate
|
||||
/// actually pins on; `ed25519Fingerprint` is display-only (docs/security.md §1.1).
|
||||
public struct ServerIdentity: Sendable, Equatable {
|
||||
public let tofuStatus: VoiceCatTofuStatus
|
||||
/// SHA-256 hex of the TLS leaf certificate — the pinned value. No separators (64 chars).
|
||||
public let tlsCertFingerprint: String
|
||||
/// Ed25519 identity fingerprint from ServerHello, hex-formatted — display only.
|
||||
/// Empty if not yet available.
|
||||
public let ed25519Fingerprint: String
|
||||
|
||||
public init(tofuStatus: VoiceCatTofuStatus, tlsCertFingerprint: String,
|
||||
ed25519Fingerprint: String) {
|
||||
self.tofuStatus = tofuStatus; self.tlsCertFingerprint = tlsCertFingerprint
|
||||
self.ed25519Fingerprint = ed25519Fingerprint
|
||||
}
|
||||
}
|
||||
@@ -1,591 +0,0 @@
|
||||
// VoiceCatClient — the public, Swift-idiomatic surface over libvoicecat. This is the Swift
|
||||
// analog of the C# client's `VoiceCatClient.cs` (clients/windows/VoiceCat.Interop).
|
||||
//
|
||||
// Key patterns carried over from the proven C# implementation (see docs/architecture.md §4
|
||||
// per-platform binding notes):
|
||||
//
|
||||
// 1. HANDLE OWNERSHIP: the class owns `vc_client*`; `deinit` calls `vc_client_destroy`
|
||||
// (which synchronously joins every internal thread, so nothing can still be reading the
|
||||
// config-string pointers or firing callbacks by the time it returns).
|
||||
//
|
||||
// 2. CONFIG STRING LIFETIMES: the core stores raw pointers from `vc_config` by value — it
|
||||
// does NOT copy the string data. `client_name`/`client_version`/`tofu_store_path` are
|
||||
// read later, whenever `connect()` actually runs on the io_thread_. So the native CString
|
||||
// storage (`_clientNamePtr` etc.) must outlive the WHOLE client, not just `init`. It's
|
||||
// freed in `deinit`, AFTER `vc_client_destroy` has returned. (C#: Marshal.StringToCoTask
|
||||
// MemUTF8 in ctor, FreeCoTaskMem in Dispose after destroy.)
|
||||
//
|
||||
// 3. EVENT DELIVERY THREAD HANDOFF: `on_event` fires on the core's event thread. Events are
|
||||
// buffered in a lock-protected array and drained on `DispatchQueue.main` — this is the
|
||||
// boundary where the core's thread hands off to the UI thread. The C# analog is
|
||||
// `Channel<VoiceCatEvent>` drained by a 30ms WinForms Timer; the Swift analog is a
|
||||
// coalesced main-queue drain (only one async block scheduled at a time). `on_event`'s
|
||||
// `text` is copied to a Swift `String` inside the callback (Callbacks.swift) before
|
||||
// enqueueing — the raw pointer is dangling by the time the main thread drains.
|
||||
//
|
||||
// 4. LEVEL METER COALESCING: `on_level` fires far more often than `on_event` and
|
||||
// intermediate values are visually irrelevant — coalesced to "latest sample per
|
||||
// stream_id" in a lock-protected dictionary, drained on main alongside events.
|
||||
// (C#: ConcurrentDictionary<uint,float> cleared in PumpEvents.)
|
||||
//
|
||||
// 5. IMMEDIATE vc_free_* ON LIST READS: `listChannels()`/`listUsers()`/etc. walk the native
|
||||
// array, convert to Swift value types, and call `vc_free_*_list` INSIDE the function —
|
||||
// callers never manage native list lifetime. (C#: Marshaling.ToManaged does the same.)
|
||||
|
||||
import VoiceCatC
|
||||
import Foundation
|
||||
|
||||
/// Swift-idiomatic alias for the C `vc_pcm_sink_cb` function-pointer type from
|
||||
/// `voicecat.h`. Exposed publicly so consumers (`VoiceCatMac`, tests) can declare a sink
|
||||
/// callback without directly importing the `VoiceCatC` C module. Mirrors the C# wrapper's
|
||||
/// `VcPcmSinkCallback` delegate.
|
||||
public typealias VoiceCatPcmSinkCallback = vc_pcm_sink_cb
|
||||
|
||||
/// The Swift wrapper around `vc_client*`. Owns the native handle for its entire lifetime;
|
||||
/// `deinit` destroys it. Events and level meters are delivered on the main queue via the
|
||||
/// `onEvent` / `onLevel` closures.
|
||||
///
|
||||
/// Thread-safety: the public methods are not thread-safe — call them from the main thread
|
||||
/// (the standard AppKit/SwiftUI pattern). The internal event/level buffers are thread-safe
|
||||
/// (lock-protected) because they're written from the core's event thread.
|
||||
public final class VoiceCatClient {
|
||||
|
||||
// MARK: - Stored properties
|
||||
|
||||
/// The opaque C handle (`vc_client*` — Swift imports the incomplete C struct as
|
||||
/// `OpaquePointer`). Set in `init`, passed to every C function, destroyed in `deinit`.
|
||||
private var handle: OpaquePointer?
|
||||
|
||||
/// Unmanaged pointer to `self` — passed as `vc_callbacks.user` so the C function-pointer
|
||||
/// callbacks can resolve back to this instance. `passUnretained` (not `passRetained`)
|
||||
/// because we want normal ARC to control the object's lifetime — `deinit` calls
|
||||
/// `vc_client_destroy` (joins all threads) before the object's memory is freed, so no
|
||||
/// callback can fire with a dangling `user` pointer. See Callbacks.swift.
|
||||
///
|
||||
/// Computed (not stored) to break a circular init dependency: it needs `self`, but
|
||||
/// stored properties must be initialized before `self` is available. `Unmanaged.passUn
|
||||
/// retained(self).toOpaque()` always returns the same address for a given instance, so
|
||||
/// computing it on demand is safe and consistent.
|
||||
private var selfPointer: UnsafeMutableRawPointer {
|
||||
Unmanaged.passUnretained(self).toOpaque()
|
||||
}
|
||||
|
||||
/// Native CString storage backing `vc_config` — must outlive the whole client (the core
|
||||
/// stores raw pointers, doesn't copy). Freed in `deinit` after `vc_client_destroy`.
|
||||
private var clientNamePtr: UnsafeMutablePointer<CChar>?
|
||||
private var clientVersionPtr: UnsafeMutablePointer<CChar>?
|
||||
private var tofuStorePathPtr: UnsafeMutablePointer<CChar>?
|
||||
|
||||
// MARK: - Event / level delivery (main-queue)
|
||||
|
||||
/// Called on the main queue for every event, in order, never coalesced. Set this from
|
||||
/// the main thread (AppKit/SwiftUI) to drive your UI.
|
||||
public var onEvent: ((VoiceCatEvent) -> Void)?
|
||||
|
||||
/// Called on the main queue with the latest RMS level per stream_id since the last drain.
|
||||
/// Intermediate values are coalesced (only the latest per stream_id is delivered).
|
||||
public var onLevel: ((UInt32, Float) -> Void)?
|
||||
|
||||
/// Lock-protected buffers, written from the core's event thread, drained on main.
|
||||
private let bufferLock = NSLock()
|
||||
private var eventBuffer: [VoiceCatEvent] = []
|
||||
private var levelSamples: [UInt32: Float] = [:]
|
||||
private var drainScheduled = false
|
||||
|
||||
// MARK: - Init / deinit
|
||||
|
||||
/// Create a client. `config.clientName`/`clientVersion`/`tofuStorePath` are copied to
|
||||
/// native CString storage held for the client's entire lifetime (the core reads them
|
||||
/// later, e.g. when `connect()` runs on the io thread).
|
||||
public init(config: VoiceCatConfig) {
|
||||
// Allocate native C strings — must persist until after vc_client_destroy in deinit.
|
||||
// These don't need `self`, so they're safe to set first.
|
||||
self.clientNamePtr = strdup(config.clientName)
|
||||
self.clientVersionPtr = strdup(config.clientVersion)
|
||||
self.tofuStorePathPtr = config.tofuStorePath.flatMap { strdup($0) }
|
||||
self.handle = nil // placeholder — set below after callbacks are wired
|
||||
|
||||
// All stored properties are now initialized → `self` is fully available, so we can
|
||||
// call `selfPointer` (the computed property) to build the callbacks struct.
|
||||
var nativeConfig = vc_config()
|
||||
nativeConfig.client_name = UnsafePointer(clientNamePtr)
|
||||
nativeConfig.client_version = UnsafePointer(clientVersionPtr)
|
||||
nativeConfig.log_level = config.logLevel.cValue
|
||||
nativeConfig.tofu_store_path = UnsafePointer(tofuStorePathPtr)
|
||||
|
||||
let callbacks = Callbacks.make(user: selfPointer)
|
||||
self.handle = vc_client_create(&nativeConfig, callbacks)
|
||||
|
||||
if handle == nil {
|
||||
free(clientNamePtr); clientNamePtr = nil
|
||||
free(clientVersionPtr); clientVersionPtr = nil
|
||||
if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil }
|
||||
fatalError("vc_client_create returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let handle {
|
||||
// Joins every internal thread synchronously — no callbacks can fire after this
|
||||
// returns, so the selfPointer and config-string pointers are safe to free.
|
||||
vc_client_destroy(handle)
|
||||
self.handle = nil
|
||||
}
|
||||
// Free config strings AFTER destroy (the core may have been reading them up until
|
||||
// destroy joined the io thread).
|
||||
free(clientNamePtr); clientNamePtr = nil
|
||||
free(clientVersionPtr); clientVersionPtr = nil
|
||||
if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil }
|
||||
}
|
||||
|
||||
// MARK: - Internal: event/level enqueue (called from the core's event thread)
|
||||
|
||||
/// Called by Callbacks.onEvent on the core's event thread. Buffers the event and
|
||||
/// schedules a coalesced main-queue drain.
|
||||
internal func enqueueEvent(_ event: VoiceCatEvent) {
|
||||
bufferLock.lock()
|
||||
eventBuffer.append(event)
|
||||
let shouldSchedule = !drainScheduled
|
||||
drainScheduled = true
|
||||
bufferLock.unlock()
|
||||
if shouldSchedule {
|
||||
DispatchQueue.main.async { [weak self] in self?.drain() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by Callbacks.onLevel on the core's event thread. Coalesces to latest-per-stream
|
||||
/// and schedules a coalesced main-queue drain.
|
||||
internal func enqueueLevel(_ streamId: UInt32, _ rms: Float) {
|
||||
bufferLock.lock()
|
||||
levelSamples[streamId] = rms
|
||||
let shouldSchedule = !drainScheduled
|
||||
drainScheduled = true
|
||||
bufferLock.unlock()
|
||||
if shouldSchedule {
|
||||
DispatchQueue.main.async { [weak self] in self?.drain() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains buffered events + coalesced levels on the main queue. Only one drain is
|
||||
/// scheduled at a time (debounced via `drainScheduled`).
|
||||
private func drain() {
|
||||
bufferLock.lock()
|
||||
let events = eventBuffer
|
||||
eventBuffer.removeAll()
|
||||
let levels = levelSamples
|
||||
levelSamples.removeAll()
|
||||
drainScheduled = false
|
||||
bufferLock.unlock()
|
||||
|
||||
for event in events { onEvent?(event) }
|
||||
for (streamId, rms) in levels { onLevel?(streamId, rms) }
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle (statics)
|
||||
|
||||
/// The core's version string (e.g. "VoiceCat 0.0.1 (protocol v1)"). Static literal — never freed.
|
||||
public static var versionString: String {
|
||||
String(cString: vc_version_string())
|
||||
}
|
||||
|
||||
/// Human-readable description of a result code. Static literal — never freed.
|
||||
public static func resultString(_ code: VoiceCatResult) -> String {
|
||||
String(cString: vc_result_string(code.cValue))
|
||||
}
|
||||
|
||||
// MARK: - Connection & auth (async; results via onEvent)
|
||||
|
||||
@discardableResult
|
||||
public func connect(host: String, port: UInt16) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_connect(handle, host, port))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func disconnect() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_disconnect(handle))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func authenticateGuest(_ nickname: String) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_authenticate_guest(handle, nickname))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func authenticateUser(_ username: String, password: String) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_authenticate_user(handle, username, password))
|
||||
}
|
||||
|
||||
// MARK: - TOFU server-identity gate (M4)
|
||||
|
||||
/// Accept or reject the pending server-identity check. Call after a `.serverIdentity`
|
||||
/// event. `accept=true` on firstConnect/mismatch updates the pin file and proceeds;
|
||||
/// `accept=false` aborts (emits `.disconnected` with `.crypto`). See docs/security.md §1.1.
|
||||
@discardableResult
|
||||
public func confirmServerIdentity(accept: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_confirm_server_identity(handle, accept ? 1 : 0))
|
||||
}
|
||||
|
||||
/// The Ed25519 identity fingerprint from ServerHello, hex-formatted — DISPLAY ONLY, not
|
||||
/// the value the TOFU gate pins on (see docs/security.md §1.1). Empty if not yet available.
|
||||
/// Uses the two-call idiom: query size with nil buffer, then allocate + fetch.
|
||||
public func getServerIdentityDisplay() -> String {
|
||||
var len: Int = 0
|
||||
_ = vc_get_server_identity_display(handle, nil, 0, &len)
|
||||
if len == 0 { return "" }
|
||||
let buf = UnsafeMutablePointer<CChar>.allocate(capacity: len + 1)
|
||||
defer { buf.deallocate() }
|
||||
_ = vc_get_server_identity_display(handle, buf, len + 1, &len)
|
||||
return String(cString: buf)
|
||||
}
|
||||
|
||||
// MARK: - Channels
|
||||
|
||||
/// Join a channel. Result arrives as a `.joinResult` event (not via the return value,
|
||||
/// which only reflects "request queued"). `password` is for password-protected channels.
|
||||
@discardableResult
|
||||
public func joinChannel(_ channelId: UInt32, password: String? = nil) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_join_channel(handle, channelId, password))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func leaveChannel() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_leave_channel(handle))
|
||||
}
|
||||
|
||||
/// Pull the current channel tree. Re-call after `.channelList`/`.userJoined`/`.userLeft`/
|
||||
/// `.userUpdated` events. The native list is freed inside this call — callers never
|
||||
/// manage native lifetime.
|
||||
public func listChannels() -> [Channel] {
|
||||
var native = vc_channel_list()
|
||||
_ = vc_list_channels(handle, &native)
|
||||
return Marshaling.channels(&native)
|
||||
}
|
||||
|
||||
public func listUsers() -> [User] {
|
||||
var native = vc_user_list()
|
||||
_ = vc_list_users(handle, &native)
|
||||
return Marshaling.users(&native)
|
||||
}
|
||||
|
||||
public func listUserStreams(_ userId: UInt32) -> [StreamSummary] {
|
||||
var native = vc_stream_summary_list()
|
||||
let r = vc_list_user_streams(handle, userId, &native)
|
||||
guard r == VC_OK else { return [] }
|
||||
return Marshaling.streamSummaries(&native)
|
||||
}
|
||||
|
||||
// MARK: - Local media streams
|
||||
|
||||
/// Start a mic / screen-audio / aux stream. Returns `(result, streamId)` — `streamId`
|
||||
/// is non-zero on success. The `label` and `deviceId` C strings are only needed for the
|
||||
/// duration of the call (the core copies what it needs), so we use temporary strdup'd
|
||||
/// buffers freed via `defer`.
|
||||
@discardableResult
|
||||
public func startStream(_ descriptor: StreamDescriptor) -> (VoiceCatResult, UInt32) {
|
||||
var streamId: UInt32 = 0
|
||||
let labelPtr = strdup(descriptor.label)
|
||||
defer { free(labelPtr) }
|
||||
let deviceIdPtr = descriptor.deviceId.flatMap { strdup($0) }
|
||||
defer { if let deviceIdPtr { free(deviceIdPtr) } }
|
||||
|
||||
var desc = vc_stream_desc()
|
||||
desc.kind = descriptor.kind.cValue
|
||||
desc.device_id = deviceIdPtr.map { UnsafePointer($0) }
|
||||
desc.label = UnsafePointer(labelPtr)
|
||||
|
||||
let r = vc_stream_start(handle, &desc, &streamId)
|
||||
return (VoiceCatResult(r), streamId)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func stopStream(_ streamId: UInt32) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_stream_stop(handle, streamId))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setInputDevice(streamId: UInt32, deviceId: String?) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_input_device(handle, streamId, deviceId))
|
||||
}
|
||||
|
||||
/// Set the capture channel count for a local MIC stream (1 = mono, 2 = stereo interleaved).
|
||||
/// Takes effect on the next AudioEngine restart (immediately if already running). Used by
|
||||
/// the iOS `IOSAudioRouter` when the user picks stereo built-in mic capture.
|
||||
@discardableResult
|
||||
public func setCaptureChannels(streamId: UInt32, channels: UInt32) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_capture_channels(handle, streamId, channels))
|
||||
}
|
||||
|
||||
// MARK: - External PCM feed / tap
|
||||
|
||||
/// External PCM feed — drives a local stream's encode pipeline with caller-supplied PCM
|
||||
/// instead of (or in addition to) a hardware capture device. Intended for ReplayKit
|
||||
/// Broadcast Extension (iOS), ScreenCaptureKit (macOS), bots, and soundboard use cases.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - streamId: The stream returned by `startStream`. Must be active.
|
||||
/// - pcm: Raw int16 PCM pointer. Caller must keep the buffer alive for the duration of the call.
|
||||
/// - samplesPerChannel: Samples per channel (e.g. 960 for 20 ms @ 48 kHz).
|
||||
/// - channels: 1 (mono) or 2 (stereo interleaved L/R).
|
||||
@discardableResult
|
||||
public func feedPcm(streamId: UInt32, pcm: UnsafePointer<Int16>,
|
||||
samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_stream_feed_pcm(handle, streamId, pcm,
|
||||
samplesPerChannel, channels))
|
||||
}
|
||||
|
||||
/// Convenience overload for feeding from a Swift `[Int16]` array.
|
||||
@discardableResult
|
||||
public func feedPcm(streamId: UInt32, pcm: [Int16],
|
||||
samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult {
|
||||
pcm.withUnsafeBufferPointer {
|
||||
feedPcm(streamId: streamId, pcm: $0.baseAddress!,
|
||||
samplesPerChannel: samplesPerChannel, channels: channels)
|
||||
}
|
||||
}
|
||||
|
||||
/// External PCM tap — receive decoded per-stream audio as raw int16 PCM before it
|
||||
/// reaches the hardware mix. Fires once per decoded Opus frame per remote stream.
|
||||
///
|
||||
/// The callback is a C function pointer (`@convention(c)`) receiving:
|
||||
/// `(user, userId, streamId, pcm, samplesPerChannel, channels, sampleRate)`
|
||||
///
|
||||
/// Pass `nil` to disable (default). The callback MUST NOT block or allocate.
|
||||
@discardableResult
|
||||
public func setPcmSink(_ cb: VoiceCatPcmSinkCallback?, user: UnsafeMutableRawPointer?) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_pcm_sink(handle, cb, user))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setInputMode(_ mode: VoiceCatInputMode) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_input_mode(handle, mode.cValue))
|
||||
}
|
||||
|
||||
/// VAD threshold: normalized RMS 0.0–1.0 (default ~0.025). Takes effect immediately.
|
||||
@discardableResult
|
||||
public func setVadThreshold(_ threshold: Float) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_vad_threshold(handle, threshold))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setPushToTalk(_ active: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_push_to_talk(handle, active ? 1 : 0))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setSelfMute(micMuted: Bool, deafened: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0))
|
||||
}
|
||||
|
||||
/// Global playback volume applied after mixing all remote streams. gain 0.0 = silent,
|
||||
/// 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. Mirrors the
|
||||
/// Windows client's `SetOutputVolume` and the C ABI `vc_set_output_volume` added in M5.
|
||||
@discardableResult
|
||||
public func setOutputVolume(_ gain: Float) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain))
|
||||
}
|
||||
|
||||
// MARK: - AVAudioSession interruption hooks (iOS)
|
||||
|
||||
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
|
||||
@discardableResult
|
||||
public func audioSuspend() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_audio_suspend(handle))
|
||||
}
|
||||
|
||||
/// Resume miniaudio device I/O. Call after re-activating AVAudioSession.
|
||||
@discardableResult
|
||||
public func audioResume() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_audio_resume(handle))
|
||||
}
|
||||
|
||||
/// Full audio engine restart — uninitialize and re-initialize the capture and playback
|
||||
/// devices so they pick up a new AVAudioSession route. Call this AFTER reconfiguring
|
||||
/// AVAudioSession (setCategory, setPreferredInput, setPreferredPolarPattern, etc.) so the
|
||||
/// core's devices reopen against the new route. Unlike `audioSuspend()`/`audioResume()`
|
||||
/// (which only stop/start the existing devices, leaving them bound to the route that was
|
||||
/// active when they were opened), this fully re-initializes them. Safe to call when the
|
||||
/// engine is not running (it will just start it).
|
||||
@discardableResult
|
||||
public func audioRestart() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_audio_restart(handle))
|
||||
}
|
||||
|
||||
// MARK: - Receive-side, per remote stream (LOCAL — no protocol traffic; docs/voice.md §10)
|
||||
|
||||
@discardableResult
|
||||
public func setRemoteStream(userId: UInt32, streamId: UInt32, gain: Float,
|
||||
muted: Bool, noiseReduction: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_remote_stream(handle, userId, streamId, gain,
|
||||
muted ? 1 : 0, noiseReduction ? 1 : 0))
|
||||
}
|
||||
|
||||
public func getRemoteStream(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, RemoteStreamState?) {
|
||||
var state = vc_remote_stream_state()
|
||||
let r = vc_get_remote_stream(handle, userId, streamId, &state)
|
||||
guard r == VC_OK else { return (VoiceCatResult(r), nil) }
|
||||
return (VoiceCatResult(r), Marshaling.remoteStreamState(state))
|
||||
}
|
||||
|
||||
public func getStreamAudioConfig(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, AudioConfig?) {
|
||||
var cfg = vc_audio_config()
|
||||
let r = vc_get_stream_audio_config(handle, userId, streamId, &cfg)
|
||||
guard r == VC_OK else { return (VoiceCatResult(r), nil) }
|
||||
return (VoiceCatResult(r), Marshaling.audioConfig(cfg))
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
|
||||
@discardableResult
|
||||
public func sendText(scope: VoiceCatTextScope, targetId: UInt32, text: String) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_send_text(handle, scope.cValue, targetId, text))
|
||||
}
|
||||
|
||||
// MARK: - Device enumeration (works pre-connect)
|
||||
|
||||
public func listDevices(_ kind: VoiceCatDeviceKind) -> [Device] {
|
||||
var native = vc_device_list()
|
||||
_ = vc_list_devices(handle, kind.cValue, &native)
|
||||
return Marshaling.devices(&native)
|
||||
}
|
||||
|
||||
// MARK: - M5: Moderation
|
||||
|
||||
@discardableResult
|
||||
public func kickUser(_ userId: UInt32, reason: String? = nil) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_kick_user(handle, userId, reason))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func banUser(_ userId: UInt32, reason: String? = nil,
|
||||
expiresUnixMs: UInt64 = 0) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_ban_user(handle, userId, reason, expiresUnixMs))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setPermission(_ userId: UInt32, perms: Permissions) -> VoiceCatResult {
|
||||
var native = vc_permissions()
|
||||
native.can_create_temp_channel = perms.canCreateTempChannel ? 1 : 0
|
||||
native.can_kick = perms.canKick ? 1 : 0
|
||||
native.can_ban = perms.canBan ? 1 : 0
|
||||
native.can_move_users = perms.canMoveUsers ? 1 : 0
|
||||
native.can_admin_accounts = perms.canAdminAccounts ? 1 : 0
|
||||
native.is_admin = perms.isAdmin ? 1 : 0
|
||||
return VoiceCatResult(vc_set_permission(handle, userId, &native))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_server_mute(handle, userId, muted ? 1 : 0, deafened ? 1 : 0))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func moveUser(_ userId: UInt32, toChannel channelId: UInt32) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_move_user(handle, userId, channelId))
|
||||
}
|
||||
|
||||
// MARK: - M5: Channel admin
|
||||
|
||||
@discardableResult
|
||||
public func createChannel(_ info: ChannelEdit) -> VoiceCatResult {
|
||||
var native = vc_channel_info()
|
||||
Self.populateChannelInfo(&native, from: info)
|
||||
defer { Self.freeChannelInfoStrings(&native) }
|
||||
return VoiceCatResult(vc_create_channel(handle, &native))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func editChannel(_ info: ChannelEdit) -> VoiceCatResult {
|
||||
var native = vc_channel_info()
|
||||
Self.populateChannelInfo(&native, from: info)
|
||||
defer { Self.freeChannelInfoStrings(&native) }
|
||||
return VoiceCatResult(vc_edit_channel(handle, &native))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func deleteChannel(_ channelId: UInt32) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_delete_channel(handle, channelId))
|
||||
}
|
||||
|
||||
// MARK: - M5: Account admin
|
||||
|
||||
@discardableResult
|
||||
public func createAccount(_ username: String, password: String) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_create_account(handle, username, password))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func resetPassword(_ username: String, newPassword: String) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_reset_password(handle, username, newPassword))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func deleteAccount(_ username: String) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_delete_account(handle, username))
|
||||
}
|
||||
|
||||
/// Request the account list — result arrives as a `.accountList` event, then call
|
||||
/// `listAccounts()` to pull the cached list.
|
||||
@discardableResult
|
||||
public func requestAccountList() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_list_accounts(handle))
|
||||
}
|
||||
|
||||
public func listAccounts() -> [Account] {
|
||||
var native = vc_account_list()
|
||||
_ = vc_get_account_list(handle, &native)
|
||||
return Marshaling.accounts(&native)
|
||||
}
|
||||
|
||||
public func getPermissions() -> Permissions {
|
||||
var native = vc_permissions()
|
||||
_ = vc_get_permissions(handle, &native)
|
||||
return Marshaling.permissions(native)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers for vc_channel_info / vc_audio_config construction
|
||||
|
||||
extension VoiceCatClient {
|
||||
/// Populate a `vc_channel_info` from a Swift `ChannelEdit`. The string fields
|
||||
/// (`name`/`topic`/`password`) are strdup'd — the caller MUST call
|
||||
/// `freeChannelInfoStrings(_:)` after the C call returns (the core copies what it needs
|
||||
/// during the call, so the temporary buffers can be freed via `defer`).
|
||||
internal static func populateChannelInfo(_ native: inout vc_channel_info, from info: ChannelEdit) {
|
||||
native.id = info.id
|
||||
native.parent_id = info.parentId
|
||||
native.name = UnsafePointer(strdup(info.name))
|
||||
native.topic = UnsafePointer(strdup(info.topic))
|
||||
native.password_protected = info.passwordProtected ? 1 : 0
|
||||
native.password = (info.passwordProtected && !(info.password?.isEmpty ?? true))
|
||||
? UnsafePointer(strdup(info.password!)) : nil
|
||||
native.max_users = info.maxUsers
|
||||
native.sort_order = info.sortOrder
|
||||
native.audio = info.audio.toNative()
|
||||
}
|
||||
|
||||
/// Free the strdup'd string fields of a `vc_channel_info` populated by
|
||||
/// `populateChannelInfo`. Call this in a `defer` after the C call.
|
||||
internal static func freeChannelInfoStrings(_ native: inout vc_channel_info) {
|
||||
if let p = native.name { free(UnsafeMutablePointer(mutating: p)); native.name = nil }
|
||||
if let p = native.topic { free(UnsafeMutablePointer(mutating: p)); native.topic = nil }
|
||||
if let p = native.password { free(UnsafeMutablePointer(mutating: p)); native.password = nil }
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioConfig {
|
||||
/// Convert to a native `vc_audio_config`.
|
||||
internal func toNative() -> vc_audio_config {
|
||||
var n = vc_audio_config()
|
||||
n.codec = codec
|
||||
n.mode = stereo ? 1 : 0
|
||||
n.sample_rate = sampleRate
|
||||
n.bitrate_bps = bitrateBps
|
||||
n.frame_ms = frameMs
|
||||
n.application = application
|
||||
n.fec = fec ? 1 : 0
|
||||
n.expected_packet_loss = expectedPacketLoss
|
||||
n.dtx = dtx ? 1 : 0
|
||||
n.complexity = complexity
|
||||
return n
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// ExternalPcmTests — Swift wrapper smoke tests for vc_stream_feed_pcm / vc_set_pcm_sink.
|
||||
//
|
||||
// These tests verify that the Swift API surface compiles, is callable, and returns expected
|
||||
// results at the C-ABI boundary — without requiring a live server or audio hardware.
|
||||
// Full end-to-end relay / decode verification is covered by tests/test_external_pcm.cpp
|
||||
// (C++ ctest), which runs headlessly on all platforms.
|
||||
|
||||
import XCTest
|
||||
@testable import VoiceCatCore
|
||||
|
||||
final class ExternalPcmTests: XCTestCase {
|
||||
|
||||
// MARK: - feedPcm: API surface smoke
|
||||
|
||||
/// Calling feedPcm without a connected client or active stream must return .invalidArg
|
||||
/// (not crash). Proves the Swift→C 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 Swift→C boundary. These tests
|
||||
// catch the same class of bugs the C# P/Invoke tests catch, for Swift.
|
||||
//
|
||||
// Prerequisites: `cmake --preset dev && cmake --build --preset dev` (builds voicecat-server
|
||||
// and voicecat-admin into build/dev/bin/), AND `scripts/build-xcframework.sh` (builds the
|
||||
// VoiceCatCore.xcframework that the Swift Package links).
|
||||
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import VoiceCatCore
|
||||
|
||||
/// Manages a real `voicecat-server` process for the test suite's lifetime. Starts the server
|
||||
/// on an ephemeral port (--port 0), parses the bound port from stdout, and provisions a known
|
||||
/// admin account via `voicecat-admin`. Killed + cleaned up in deinit.
|
||||
private final class ServerHarness {
|
||||
let port: UInt16
|
||||
private let process: Process
|
||||
let tempDir: String
|
||||
|
||||
init() throws {
|
||||
let repoRoot = Self.findRepoRoot()
|
||||
let serverURL = URL(fileURLWithPath: repoRoot)
|
||||
.appendingPathComponent("build/dev/bin/voicecat-server")
|
||||
|
||||
guard FileManager.default.isExecutableFile(atPath: serverURL.path) else {
|
||||
throw NSError(domain: "VoiceCatTest", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "voicecat-server not found at \(serverURL.path) — "
|
||||
+ "build the dev preset first: cmake --preset dev && cmake --build --preset dev",
|
||||
])
|
||||
}
|
||||
|
||||
let tempDir = NSTemporaryDirectory() + "vc_swift_smoke_" + UUID().uuidString
|
||||
try FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: true)
|
||||
self.tempDir = tempDir
|
||||
|
||||
let p = Process()
|
||||
p.executableURL = serverURL
|
||||
p.arguments = ["--port", "0", "--data-dir", tempDir, "--name", "SwiftSmokeTest"]
|
||||
|
||||
// Pipe stdout to read the bound port; stderr to /dev/null.
|
||||
let stdoutPipe = Pipe()
|
||||
p.standardOutput = stdoutPipe
|
||||
p.standardError = FileHandle(forWritingAtPath: "/dev/null")
|
||||
try p.run()
|
||||
self.process = p
|
||||
|
||||
// Parse "[voicecat-server] ... — TCP :<port> UDP :<port>" from stdout. The server
|
||||
// prints several lines before the port line (version, first-run admin box, etc.), so
|
||||
// we keep reading until we find a line matching "TCP :<port>". Read with a 10s timeout
|
||||
// so a crashed/hung server can't hang the test forever.
|
||||
guard let port = Self.readPortWithTimeout(stdoutPipe, timeout: 10) else {
|
||||
p.terminate()
|
||||
throw NSError(domain: "VoiceCatTest", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "voicecat-server did not report a bound TCP port within 10s",
|
||||
])
|
||||
}
|
||||
self.port = port
|
||||
|
||||
// Provision a known admin account for moderation/admin tests (M5).
|
||||
let adminURL = URL(fileURLWithPath: repoRoot)
|
||||
.appendingPathComponent("build/dev/bin/voicecat-admin")
|
||||
guard FileManager.default.isExecutableFile(atPath: adminURL.path) else {
|
||||
throw NSError(domain: "VoiceCatTest", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "voicecat-admin not found at \(adminURL.path)",
|
||||
])
|
||||
}
|
||||
let adminProc = Process()
|
||||
adminProc.executableURL = adminURL
|
||||
adminProc.arguments = ["--data-dir", tempDir, "account", "add", "admin2",
|
||||
"--admin", "--password", "testpassword123"]
|
||||
adminProc.standardOutput = FileHandle(forWritingAtPath: "/dev/null")
|
||||
adminProc.standardError = FileHandle(forWritingAtPath: "/dev/null")
|
||||
try adminProc.run()
|
||||
adminProc.waitUntilExit()
|
||||
guard adminProc.terminationStatus == 0 else {
|
||||
throw NSError(domain: "VoiceCatTest", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "voicecat-admin failed to provision admin2 (exit \(adminProc.terminationStatus))",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if process.isRunning { process.terminate() }
|
||||
try? FileManager.default.removeItem(atPath: tempDir)
|
||||
}
|
||||
|
||||
private static func findRepoRoot() -> String {
|
||||
var url = URL(fileURLWithPath: #file)
|
||||
while url.path != "/" && !FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) {
|
||||
url = url.deletingLastPathComponent()
|
||||
}
|
||||
guard FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) else {
|
||||
fatalError("Could not find repo root (CMakePresets.json) above \(#file)")
|
||||
}
|
||||
return url.path
|
||||
}
|
||||
|
||||
/// Read from the server's stdout until a line matching "TCP :<port>" is found, or the
|
||||
/// timeout expires. The server prints several lines (version banner, first-run admin box,
|
||||
/// etc.) before the port line — see server/src/server.cpp.
|
||||
private static func readPortWithTimeout(_ pipe: Pipe, timeout: TimeInterval) -> UInt16? {
|
||||
let handle = pipe.fileHandleForReading
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var buffer = Data()
|
||||
while Date() < deadline {
|
||||
let data = handle.availableData
|
||||
if !data.isEmpty {
|
||||
buffer.append(data)
|
||||
// Check each complete line in the buffer for "TCP :<port>".
|
||||
while let newlineIdx = buffer.firstIndex(of: 0x0A) {
|
||||
let lineData = buffer.prefix(newlineIdx)
|
||||
buffer = buffer.suffix(from: buffer.index(after: newlineIdx))
|
||||
if let line = String(data: lineData, encoding: .utf8),
|
||||
let port = parsePort(from: line) {
|
||||
return port
|
||||
}
|
||||
}
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parsePort(from line: String) -> UInt16? {
|
||||
// Match "TCP :<port>" — see server/src/server.cpp.
|
||||
guard let range = line.range(of: #"TCP :(\d+)"#, options: .regularExpression) else { return nil }
|
||||
let digits = line[range].split(separator: ":").last ?? ""
|
||||
return UInt16(digits.trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
}
|
||||
|
||||
/// XCTest smoke tests against a real voicecat-server, through the Swift VoiceCatClient wrapper.
|
||||
final class VoiceCatClientSmokeTests: XCTestCase {
|
||||
private static var harness: ServerHarness?
|
||||
|
||||
override class func setUp() {
|
||||
do {
|
||||
harness = try ServerHarness()
|
||||
} catch {
|
||||
// Store the error so each test fails with a clear message rather than a crash.
|
||||
NSLog("ServerHarness setup failed: \(error.localizedDescription)")
|
||||
harness = nil
|
||||
}
|
||||
}
|
||||
|
||||
override class func tearDown() {
|
||||
harness = nil
|
||||
}
|
||||
|
||||
private var port: UInt16 {
|
||||
guard let p = Self.harness?.port else {
|
||||
XCTFail("ServerHarness not started — see setUp error in log")
|
||||
return 0
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
private var tempDir: String {
|
||||
Self.harness?.tempDir ?? NSTemporaryDirectory()
|
||||
}
|
||||
|
||||
/// Helper: wait until the predicate is satisfied, running the main runloop to process
|
||||
/// dispatched events. The Swift analog of the C# `PumpUntil` helper. Our events are
|
||||
/// delivered via DispatchQueue.main.async, which the main runloop processes during
|
||||
/// `RunLoop.current.run(until:)`.
|
||||
///
|
||||
/// Uses RunLoop polling (not XCTestExpectation) so that the "assert something does NOT
|
||||
/// happen within N seconds" pattern works without generating spurious "Asynchronous wait
|
||||
/// failed" errors — `wait(for:timeout:)` logs an error when an expectation isn't
|
||||
/// fulfilled, which is wrong for negative checks.
|
||||
private func waitFor(timeout: TimeInterval = 5, _ predicate: @escaping () -> Bool) -> Bool {
|
||||
if predicate() { return true }
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
// Run the main runloop for ~20ms — processes DispatchQueue.main.async blocks
|
||||
// (where our events/levels are drained) and timer sources.
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
|
||||
if predicate() { return true }
|
||||
}
|
||||
return predicate()
|
||||
}
|
||||
|
||||
private func requireHarness() -> Bool {
|
||||
guard Self.harness != nil else {
|
||||
XCTFail("ServerHarness not started — see setUp error in log")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
func testVersionStringIsNonEmpty() {
|
||||
XCTAssertFalse(VoiceCatClient.versionString.isEmpty)
|
||||
}
|
||||
|
||||
func testResultStringRoundTrips() {
|
||||
XCTAssertFalse(VoiceCatClient.resultString(.ok).isEmpty)
|
||||
XCTAssertFalse(VoiceCatClient.resultString(.permissionDenied).isEmpty)
|
||||
}
|
||||
|
||||
/// Full connect → TOFU → confirm → guest auth → list channels → permissions → guest
|
||||
/// ListAccounts rejected. Mirrors the C# `Connect_Tofu_Auth_ListChannels_RoundTrips`.
|
||||
func testConnectTofuAuthListChannelsRoundTrips() throws {
|
||||
guard requireHarness() else { return }
|
||||
|
||||
var events: [VoiceCatEvent] = []
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "vc-swift-smoke",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off,
|
||||
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins.txt")
|
||||
))
|
||||
client.onEvent = { events.append($0) }
|
||||
|
||||
XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
|
||||
XCTAssertEqual(client.authenticateGuest("SwiftSmoke"), .ok)
|
||||
|
||||
// Wait for VC_EVENT_SERVER_IDENTITY.
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } },
|
||||
"did not receive .serverIdentity")
|
||||
let identityEvent = try XCTUnwrap(events.first { $0.type == .serverIdentity })
|
||||
XCTAssertEqual(identityEvent.tofuStatus, .firstConnect)
|
||||
XCTAssertNotNil(identityEvent.text)
|
||||
XCTAssertEqual(identityEvent.text?.count, 64, "SHA-256 hex, no separators")
|
||||
|
||||
// Auth must NOT complete before identity is confirmed (800ms, like the C# test).
|
||||
XCTAssertFalse(waitFor(timeout: 0.8) { events.contains { $0.type == .authResult } },
|
||||
"auth completed before identity confirmation (should be held open)")
|
||||
|
||||
XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
|
||||
|
||||
// Wait for VC_EVENT_AUTH_RESULT.
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } },
|
||||
"did not receive .authResult after confirming identity")
|
||||
let authEvent = try XCTUnwrap(events.first { $0.type == .authResult })
|
||||
XCTAssertEqual(authEvent.result, .ok)
|
||||
|
||||
// Wait for VC_EVENT_CHANNEL_LIST.
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } },
|
||||
"did not receive .channelList")
|
||||
|
||||
let channels = client.listChannels()
|
||||
XCTAssertTrue(channels.contains { $0.id == 1 && $0.name == "Lobby" },
|
||||
"expected Lobby (channel 1) in \(channels.map { $0.name })")
|
||||
|
||||
// M5: permissions getter round-trip.
|
||||
let perms = client.getPermissions()
|
||||
XCTAssertFalse(perms.isAdmin)
|
||||
XCTAssertFalse(perms.canKick)
|
||||
|
||||
// M5: guest ListAccounts is rejected by the server with a GenericResult — proves the
|
||||
// moderation wrapper path works end-to-end through the Swift interop layer.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.requestAccountList(), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult } },
|
||||
"did not receive .genericResult for guest ListAccounts")
|
||||
let generic = try XCTUnwrap(events.first { $0.type == .genericResult })
|
||||
XCTAssertEqual(generic.result, .permissionDenied)
|
||||
|
||||
client.disconnect()
|
||||
}
|
||||
|
||||
/// Admin auth → channel CRUD → account CRUD. Mirrors C# `Admin_ChannelCrud_AccountCrud_RoundTrips`.
|
||||
func testAdminChannelCrudAccountCrudRoundTrips() throws {
|
||||
guard requireHarness() else { return }
|
||||
|
||||
var events: [VoiceCatEvent] = []
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "vc-swift-admin",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off,
|
||||
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_admin.txt")
|
||||
))
|
||||
client.onEvent = { events.append($0) }
|
||||
|
||||
XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
|
||||
XCTAssertEqual(client.authenticateUser("admin2", password: "testpassword123"), .ok)
|
||||
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } })
|
||||
XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } })
|
||||
XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } })
|
||||
|
||||
let perms = client.getPermissions()
|
||||
XCTAssertTrue(perms.isAdmin || perms.canAdminAccounts)
|
||||
|
||||
// Channel CRUD — create.
|
||||
let audioConfig = AudioConfig(stereo: true, bitrateBps: 64000, frameMs: 20,
|
||||
application: 1, fec: true, expectedPacketLoss: 5, complexity: 10)
|
||||
XCTAssertEqual(client.createChannel(ChannelEdit(
|
||||
id: 0, parentId: 0, name: "Swift Test Channel", topic: "Created by Swift smoke test",
|
||||
passwordProtected: false, password: nil, maxUsers: 42, sortOrder: 0, audio: audioConfig
|
||||
)), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||
"CreateChannel did not succeed")
|
||||
|
||||
var channels = client.listChannels()
|
||||
let created = try XCTUnwrap(channels.first { $0.name == "Swift Test Channel" })
|
||||
XCTAssertEqual(created.topic, "Created by Swift smoke test")
|
||||
XCTAssertFalse(created.passwordProtected)
|
||||
|
||||
// Channel CRUD — edit.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.editChannel(ChannelEdit(
|
||||
id: created.id, parentId: created.parentId, name: created.name,
|
||||
topic: "Updated topic", passwordProtected: false, password: nil,
|
||||
maxUsers: 100, sortOrder: 0, audio: audioConfig
|
||||
)), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||
"EditChannel did not succeed")
|
||||
|
||||
// Channel CRUD — delete.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.deleteChannel(created.id), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||
"DeleteChannel did not succeed")
|
||||
|
||||
// Account CRUD — create.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.createAccount("swift_smoke_user", password: "initialpw"), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||
"CreateAccount did not succeed")
|
||||
|
||||
// Account CRUD — list.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.requestAccountList(), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .accountList } },
|
||||
"did not receive .accountList")
|
||||
let accounts = client.listAccounts()
|
||||
XCTAssertTrue(accounts.contains { $0.username == "swift_smoke_user" })
|
||||
|
||||
// Account CRUD — reset password.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.resetPassword("swift_smoke_user", newPassword: "newpw123"), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||
"ResetPassword did not succeed")
|
||||
|
||||
// Account CRUD — delete.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.deleteAccount("swift_smoke_user"), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||
"DeleteAccount did not succeed")
|
||||
|
||||
client.disconnect()
|
||||
}
|
||||
|
||||
/// Screen-audio (SCREEN_AUDIO) stream start/stop through the Swift wrapper. The core's
|
||||
/// macOS CoreAudio path starts the StreamAnnounce; this exercises the full
|
||||
/// startStream → .streamStarted → stopStream → .streamStopped path through Swift interop.
|
||||
/// Mirrors C# `ScreenAudioStream_Starts_And_Stops`.
|
||||
func testScreenAudioStreamStartsAndStops() throws {
|
||||
guard requireHarness() else { return }
|
||||
|
||||
var events: [VoiceCatEvent] = []
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "vc-swift-screen",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off,
|
||||
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_screen.txt")
|
||||
))
|
||||
client.onEvent = { events.append($0) }
|
||||
|
||||
XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
|
||||
XCTAssertEqual(client.authenticateGuest("SwiftScreen"), .ok)
|
||||
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } })
|
||||
XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } })
|
||||
XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } })
|
||||
|
||||
// Give the async UDP binding handshake a moment to land (mirrors vccli's 500ms sleep).
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
|
||||
let (startResult, streamId) = client.startStream(
|
||||
StreamDescriptor(kind: .screenAudio, label: "Desktop audio")
|
||||
)
|
||||
XCTAssertEqual(startResult, .ok)
|
||||
XCTAssertNotEqual(streamId, 0, "streamId should be non-zero on success")
|
||||
|
||||
// The core emits .streamStarted for the local client too.
|
||||
XCTAssertTrue(waitFor(timeout: 5) {
|
||||
events.contains { $0.type == .streamStarted && $0.streamId == streamId }
|
||||
}, "did not receive .streamStarted for screen-audio stream")
|
||||
|
||||
XCTAssertEqual(client.stopStream(streamId), .ok)
|
||||
XCTAssertTrue(waitFor(timeout: 5) {
|
||||
events.contains { $0.type == .streamStopped && $0.streamId == streamId }
|
||||
}, "did not receive .streamStopped for screen-audio stream")
|
||||
|
||||
client.disconnect()
|
||||
}
|
||||
|
||||
/// Per-stream receive-side controls (gain/mute/NR) round-trip through Swift: two clients
|
||||
/// in a channel, one publishes a MIC stream, the other setRemoteStream's it then
|
||||
/// getRemoteStream's it back. Catches Swift-specific marshaling bugs (field order,
|
||||
/// bool-from-int, float precision) that the C++ ctest can't. Mirrors C#
|
||||
/// `PerStream_RecvControls_Round_Trip_Through_PInvoke`.
|
||||
func testPerStreamRecvControlsRoundTrip() throws {
|
||||
guard requireHarness() else { return }
|
||||
|
||||
var eventsA: [VoiceCatEvent] = []
|
||||
var eventsB: [VoiceCatEvent] = []
|
||||
|
||||
let a = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "vc-swift-mix-a", clientVersion: "0.1", logLevel: .off,
|
||||
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_a.txt")
|
||||
))
|
||||
let b = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "vc-swift-mix-b", clientVersion: "0.1", logLevel: .off,
|
||||
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_b.txt")
|
||||
))
|
||||
a.onEvent = { eventsA.append($0) }
|
||||
b.onEvent = { eventsB.append($0) }
|
||||
|
||||
// Connect + auth A first, then B (staggering avoids concurrent TLS handshakes).
|
||||
XCTAssertEqual(a.connect(host: "127.0.0.1", port: port), .ok)
|
||||
XCTAssertEqual(a.authenticateGuest("SwiftMixA"), .ok)
|
||||
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .serverIdentity } })
|
||||
XCTAssertEqual(a.confirmServerIdentity(accept: true), .ok)
|
||||
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .authResult } })
|
||||
XCTAssertEqual(try XCTUnwrap(eventsA.first { $0.type == .authResult }).result, .ok)
|
||||
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .channelList } })
|
||||
|
||||
XCTAssertEqual(b.connect(host: "127.0.0.1", port: port), .ok)
|
||||
XCTAssertEqual(b.authenticateGuest("SwiftMixB"), .ok)
|
||||
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .serverIdentity } })
|
||||
XCTAssertEqual(b.confirmServerIdentity(accept: true), .ok)
|
||||
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .authResult } })
|
||||
XCTAssertEqual(try XCTUnwrap(eventsB.first { $0.type == .authResult }).result, .ok)
|
||||
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .channelList } })
|
||||
|
||||
// Both join Lobby (channel 1) so voice relays between them.
|
||||
XCTAssertEqual(a.joinChannel(1), .ok)
|
||||
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .joinResult } },
|
||||
"A did not receive .joinResult")
|
||||
XCTAssertEqual(b.joinChannel(1), .ok)
|
||||
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .joinResult } },
|
||||
"B did not receive .joinResult")
|
||||
|
||||
// UDP binding handshake is async; give it a moment.
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
|
||||
// A publishes a MIC stream.
|
||||
let (startResult, streamId) = a.startStream(StreamDescriptor(kind: .mic, label: "mix-test-mic"))
|
||||
XCTAssertEqual(startResult, .ok)
|
||||
XCTAssertNotEqual(streamId, 0)
|
||||
|
||||
// B sees A's stream.
|
||||
XCTAssertTrue(waitFor(timeout: 5) {
|
||||
eventsB.contains { $0.type == .streamStarted && $0.streamId == streamId }
|
||||
}, "B did not see A's .streamStarted")
|
||||
|
||||
// Resolve A's user id from B's user list.
|
||||
var aUid: UInt32 = 0
|
||||
XCTAssertTrue(waitFor(timeout: 3) {
|
||||
aUid = b.listUsers().first { $0.nickname == "SwiftMixA" }?.id ?? 0
|
||||
return aUid != 0
|
||||
}, "could not resolve A's user id on B")
|
||||
XCTAssertNotEqual(aUid, 0)
|
||||
|
||||
// B can enumerate A's stream.
|
||||
XCTAssertTrue(waitFor(timeout: 3) {
|
||||
b.listUserStreams(aUid).contains { $0.id == streamId }
|
||||
}, "B could not enumerate A's stream")
|
||||
let bStreams = b.listUserStreams(aUid)
|
||||
XCTAssertTrue(bStreams.contains { $0.id == streamId && $0.kind == .mic })
|
||||
|
||||
// Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off).
|
||||
let (r0, st0) = b.getRemoteStream(userId: aUid, streamId: streamId)
|
||||
XCTAssertEqual(r0, .ok)
|
||||
XCTAssertNotNil(st0)
|
||||
XCTAssertEqual(st0?.gain, 1.0)
|
||||
XCTAssertFalse(st0?.muted ?? true)
|
||||
XCTAssertFalse(st0?.noiseReduction ?? true)
|
||||
|
||||
// B turns A down to 0.5×, mutes, enables NR — then reads it back.
|
||||
XCTAssertEqual(b.setRemoteStream(userId: aUid, streamId: streamId,
|
||||
gain: 0.5, muted: true, noiseReduction: true), .ok)
|
||||
let (r1, st1) = b.getRemoteStream(userId: aUid, streamId: streamId)
|
||||
XCTAssertEqual(r1, .ok)
|
||||
XCTAssertNotNil(st1)
|
||||
XCTAssertEqual(st1?.gain, 0.5)
|
||||
XCTAssertTrue(st1?.muted ?? false)
|
||||
XCTAssertTrue(st1?.noiseReduction ?? false)
|
||||
|
||||
// Unknown stream id on a known user → .invalidArg.
|
||||
let (rBad, stBad) = b.getRemoteStream(userId: aUid, streamId: 0xDEADBEEF)
|
||||
XCTAssertEqual(rBad, .invalidArg)
|
||||
XCTAssertNil(stBad)
|
||||
|
||||
a.disconnect()
|
||||
b.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 0–10", 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(); }
|
||||
}
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using VoiceCat.Core;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal sealed class BroadcastAudioPump : IAsyncDisposable
|
||||
{
|
||||
private const uint Magic = 0x56434252, Version = 1;
|
||||
private const int Header = 64, Capacity = 96_000, Frame = 960;
|
||||
private readonly CancellationTokenSource stop = new();
|
||||
private readonly short[] scratch = new short[Frame * 2];
|
||||
private Thread? worker;
|
||||
private VoiceCatClient? client;
|
||||
private MemoryMappedFile? map;
|
||||
private MemoryMappedViewAccessor? view;
|
||||
private string? path;
|
||||
private uint streamId;
|
||||
private bool active;
|
||||
private int generation;
|
||||
|
||||
internal event Action? Changed;
|
||||
internal bool IsActive => active;
|
||||
|
||||
internal void Start(VoiceCatClient owner)
|
||||
{
|
||||
client = owner;
|
||||
worker = new Thread(Run) { IsBackground = true, Name = "VoiceCat iOS screen audio", Priority = ThreadPriority.AboveNormal };
|
||||
worker.Start();
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
CancellationToken token = stop.Token;
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try { DrainAsync(token).GetAwaiter().GetResult(); }
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or OperationCanceledException) { CloseMapping(); }
|
||||
if (!token.IsCancellationRequested) Thread.Sleep(5);
|
||||
}
|
||||
}
|
||||
finally { CloseMapping(); }
|
||||
}
|
||||
|
||||
private async Task DrainAsync(CancellationToken token)
|
||||
{
|
||||
if (!OpenMapping()) return;
|
||||
MemoryMappedViewAccessor pages = view!;
|
||||
bool active = pages.ReadUInt32(16) != 0;
|
||||
if (!active) { StopStream(); SetActive(false); return; }
|
||||
VoiceCatClient owner = client ?? throw new IOException("Client disconnected.");
|
||||
if (streamId == 0)
|
||||
{
|
||||
int startGeneration = Volatile.Read(ref generation);
|
||||
StreamInfo stream = await owner.StartStreamAsync(StreamKind.StreamScreenAudio, "Screen audio", 2, token).ConfigureAwait(false);
|
||||
if (startGeneration != Volatile.Read(ref generation) || pages.ReadUInt32(16) == 0 || !ReferenceEquals(client, owner))
|
||||
{
|
||||
try { owner.StopStream(stream.StreamId); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
|
||||
return;
|
||||
}
|
||||
streamId = stream.StreamId; pages.Write(32, pages.ReadUInt64(24)); SetActive(true);
|
||||
}
|
||||
ulong write = pages.ReadUInt64(24), read = pages.ReadUInt64(32);
|
||||
if (write - read > Capacity) read = write - Capacity;
|
||||
while (write - read >= Frame * 2)
|
||||
{
|
||||
for (int sample = 0; sample < scratch.Length; sample++)
|
||||
{
|
||||
ulong index = (read + (ulong)sample) % Capacity;
|
||||
scratch[sample] = pages.ReadInt16(Header + checked((long)index * sizeof(short)));
|
||||
}
|
||||
if (!owner.Audio.FeedPcm(streamId, scratch, 2)) break;
|
||||
read += (ulong)scratch.Length;
|
||||
pages.Write(32, read);
|
||||
}
|
||||
}
|
||||
|
||||
// The producer opens the ring with O_CREAT and never replaces it, so one mapping covers the
|
||||
// pump's whole life. Creating and disposing a mapping (plus its container lookup and path
|
||||
// strings) every 5 ms tick allocated steadily at 200 Hz and churned the GC under long calls;
|
||||
// the mapping is now built on the ring's transitions and reused until the file disappears or
|
||||
// a drain fails.
|
||||
private bool OpenMapping()
|
||||
{
|
||||
path ??= ResolvePath();
|
||||
if (path is null) return false;
|
||||
bool present = File.Exists(path);
|
||||
if (view is not null)
|
||||
{
|
||||
if (present) return true;
|
||||
CloseMapping(); return false;
|
||||
}
|
||||
if (!present) return false;
|
||||
MemoryMappedFile candidate = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite);
|
||||
try
|
||||
{
|
||||
MemoryMappedViewAccessor pages = candidate.CreateViewAccessor(0, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite);
|
||||
if (pages.ReadUInt32(0) != Magic || pages.ReadUInt32(4) != Version)
|
||||
{
|
||||
pages.Dispose(); throw new InvalidDataException("Unsupported broadcast ring.");
|
||||
}
|
||||
map = candidate; view = pages; return true;
|
||||
}
|
||||
catch { candidate.Dispose(); throw; }
|
||||
}
|
||||
|
||||
private static string? ResolvePath()
|
||||
{
|
||||
NSUrl? root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup);
|
||||
return root?.Path is null ? null : Path.Combine(root.Path, "voicecat", "broadcast_audio.ring");
|
||||
}
|
||||
|
||||
private void CloseMapping()
|
||||
{
|
||||
view?.Dispose(); view = null;
|
||||
map?.Dispose(); map = null;
|
||||
}
|
||||
|
||||
private void StopStream()
|
||||
{
|
||||
uint id = streamId; streamId = 0; if (id == 0 || client?.State != ClientConnectionState.Connected) return;
|
||||
try { client.StopStream(id); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
|
||||
}
|
||||
|
||||
internal void RequestStop() { Interlocked.Increment(ref generation); SetActive(false); }
|
||||
|
||||
private void SetActive(bool value) { if (active == value) return; active = value; Changed?.Invoke(); }
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
stop.Cancel(); Interlocked.Increment(ref generation); worker?.Join(); worker = null;
|
||||
StopStream(); SetActive(false); stop.Dispose(); return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using UIKit;
|
||||
using Voicecat.V1;
|
||||
using Channel = Voicecat.V1.Channel;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal sealed class ChannelEditorController : UIViewController
|
||||
{
|
||||
private readonly AppModel model; private readonly Channel? existing;
|
||||
private readonly UITextField name = UiHelpers.Field("Channel name"), topic = UiHelpers.Field("Topic (optional)"), maximum = UiHelpers.Field("Maximum users, 0 is unlimited"), order = UiHelpers.Field("Sort order"), password = UiHelpers.Field("Password; blank preserves existing", true), bitrate = UiHelpers.Field("Bitrate in bits per second"), loss = UiHelpers.Field("Expected packet loss percent"), complexity = UiHelpers.Field("Complexity 0 through 10");
|
||||
private readonly UISegmentedControl type = new(["Permanent", "Temporary"]), mode = new(["Mono", "Stereo"]), application = new(["VoIP", "Audio", "Low delay"]);
|
||||
private readonly UIButton parent = UIButton.FromType(UIButtonType.System), sampleRate = UIButton.FromType(UIButtonType.System), frame = UIButton.FromType(UIButtonType.System);
|
||||
private readonly UIButton lossSpeed = UIButton.FromType(UIButtonType.System);
|
||||
private readonly UISwitch automaticLoss = new(), fec = new(), dtx = new(), dred = new(); private uint parentId;
|
||||
|
||||
internal ChannelEditorController(AppModel model, Channel? existing) { this.model = model; this.existing = existing; Title = existing is null ? "New Channel" : "Edit Channel"; }
|
||||
public override void ViewDidLoad()
|
||||
{
|
||||
base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemGroupedBackground; UIScrollView scroll = new() { TranslatesAutoresizingMaskIntoConstraints = false }; UIStackView stack = new() { Axis = UILayoutConstraintAxis.Vertical, Spacing = 12, TranslatesAutoresizingMaskIntoConstraints = false };
|
||||
View.AddSubview(scroll); scroll.AddSubview(stack); NSLayoutConstraint.ActivateConstraints([scroll.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor), scroll.BottomAnchor.ConstraintEqualTo(View.BottomAnchor), scroll.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor), scroll.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor), stack.TopAnchor.ConstraintEqualTo(scroll.ContentLayoutGuide.TopAnchor, 16), stack.BottomAnchor.ConstraintEqualTo(scroll.ContentLayoutGuide.BottomAnchor, -24), stack.LeadingAnchor.ConstraintEqualTo(scroll.FrameLayoutGuide.LeadingAnchor, 20), stack.TrailingAnchor.ConstraintEqualTo(scroll.FrameLayoutGuide.TrailingAnchor, -20)]);
|
||||
foreach (UIView row in new UIView[] { name, topic, PickerRow("Parent channel", parent), PickerRow("Channel type", type), maximum, order, password, PickerRow("Channel mode", mode), PickerRow("Sample rate", sampleRate), PickerRow("Frame duration", frame), PickerRow("Opus application", application), bitrate, SwitchRow("Automatic packet loss", automaticLoss), PickerRow("Adaptation speed", lossSpeed), loss, complexity, SwitchRow("Forward error correction", fec), SwitchRow("Discontinuous transmission", dtx), SwitchRow("Deep redundancy", dred) }) stack.AddArrangedSubview(row);
|
||||
maximum.KeyboardType = order.KeyboardType = bitrate.KeyboardType = loss.KeyboardType = complexity.KeyboardType = UIKeyboardType.NumberPad;
|
||||
parent.Menu = UIMenu.Create(model.Channels.Where(channel => channel.Id != existing?.Id).OrderBy(channel => channel.Name).Select(channel => UIAction.Create(channel.Name, null, null, _ => { parentId = channel.Id; parent.SetTitle(channel.Name, UIControlState.Normal); })).Prepend(UIAction.Create("Root", null, null, _ => { parentId = 0; parent.SetTitle("Root", UIControlState.Normal); })).ToArray()); parent.ShowsMenuAsPrimaryAction = true;
|
||||
sampleRate.Menu = Choice(sampleRate, ["48000"]); sampleRate.ShowsMenuAsPrimaryAction = true; frame.Menu = Choice(frame, ["5", "10", "20", "40", "60"]); frame.ShowsMenuAsPrimaryAction = true;
|
||||
lossSpeed.Menu = Choice(lossSpeed, ["Fast", "Balanced", "Stable"]); lossSpeed.ShowsMenuAsPrimaryAction = true;
|
||||
automaticLoss.Enabled = model.SupportsAdaptivePacketLoss;
|
||||
automaticLoss.ValueChanged += (_, _) => UpdateLossControls();
|
||||
Load(); NavigationItem.RightBarButtonItem = new("Save", UIBarButtonItemStyle.Done, async (_, _) => await Save());
|
||||
}
|
||||
private void Load()
|
||||
{
|
||||
Channel channel = existing ?? new Channel { Audio = new AudioConfig { SampleRate = 48000, BitrateBps = 64000, FrameMs = 20, Fec = true, Complexity = 10, ExpectedPacketLoss = 5 } };
|
||||
name.Text = channel.Name; topic.Text = channel.Topic; maximum.Text = channel.MaxUsers.ToString(); order.Text = channel.Order.ToString(); parentId = channel.ParentId;
|
||||
parent.SetTitle(model.Channels.FirstOrDefault(value => value.Id == parentId)?.Name ?? "Root", UIControlState.Normal); type.SelectedSegment = channel.Type == ChannelType.ChannelTemporary ? 1 : 0; mode.SelectedSegment = channel.Audio?.Mode == ChannelMode.ModeStereo ? 1 : 0;
|
||||
sampleRate.SetTitle((channel.Audio?.SampleRate ?? 48000).ToString(), UIControlState.Normal); frame.SetTitle((channel.Audio?.FrameMs ?? 20).ToString(), UIControlState.Normal); application.SelectedSegment = (nint)(channel.Audio?.Application ?? OpusApplication.OpusVoip);
|
||||
bitrate.Text = (channel.Audio?.BitrateBps ?? 64000).ToString(); loss.Text = (channel.Audio?.ExpectedPacketLoss ?? 5).ToString(); complexity.Text = (channel.Audio?.Complexity ?? 10).ToString(); fec.On = channel.Audio?.Fec ?? true; dtx.On = channel.Audio?.Dtx ?? false; dred.On = channel.Audio?.Dred ?? false;
|
||||
automaticLoss.On = channel.Audio?.PacketLossMode != PacketLossMode.PacketLossManual;
|
||||
lossSpeed.SetTitle(channel.Audio?.PacketLossMode switch { PacketLossMode.PacketLossAutoFast => "Fast", PacketLossMode.PacketLossAutoStable => "Stable", _ => "Balanced" }, UIControlState.Normal);
|
||||
UpdateLossControls();
|
||||
}
|
||||
private async Task Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name.Text) || !uint.TryParse(maximum.Text, out uint max) || !int.TryParse(order.Text, out int sort) || !uint.TryParse(sampleRate.Title(UIControlState.Normal), out uint rate) || !uint.TryParse(frame.Title(UIControlState.Normal), out uint frameMs) || !uint.TryParse(bitrate.Text, out uint bits) || !uint.TryParse(loss.Text, out uint packetLoss) || !uint.TryParse(complexity.Text, out uint cpu) || packetLoss > 100 || cpu > 10) throw new ArgumentException("Enter valid channel and Opus settings.");
|
||||
var channel = new Channel { Id = existing?.Id ?? 0, ParentId = parentId, Name = name.Text.Trim(), Topic = topic.Text?.Trim() ?? "", MaxUsers = max, Order = sort, Type = type.SelectedSegment == 1 ? ChannelType.ChannelTemporary : ChannelType.ChannelPermanent,
|
||||
Audio = new AudioConfig { Codec = 0, Mode = mode.SelectedSegment == 1 ? ChannelMode.ModeStereo : ChannelMode.ModeMono, SampleRate = rate, FrameMs = frameMs, Application = (OpusApplication)(int)application.SelectedSegment, BitrateBps = bits, ExpectedPacketLoss = packetLoss, Complexity = cpu, Fec = fec.On, Dtx = dtx.On, Dred = dred.On,
|
||||
PacketLossMode = automaticLoss.On ? lossSpeed.Title(UIControlState.Normal) switch { "Fast" => PacketLossMode.PacketLossAutoFast, "Stable" => PacketLossMode.PacketLossAutoStable, _ => PacketLossMode.PacketLossAutoBalanced } : PacketLossMode.PacketLossManual } };
|
||||
GenericResult result = await model.RunAdminAsync(client => existing is null ? client.CreateChannelAsync(channel, password.Text ?? "") : client.EditChannelAsync(channel, password.Text ?? "")); if (!result.Ok) throw new InvalidOperationException(result.Message); NavigationController?.PopViewController(true);
|
||||
}
|
||||
catch (Exception exception) { UiHelpers.ShowError(this, exception); }
|
||||
}
|
||||
private void UpdateLossControls() { loss.Enabled = !automaticLoss.On; lossSpeed.Enabled = model.SupportsAdaptivePacketLoss && automaticLoss.On; }
|
||||
private static UIView PickerRow(string label, UIView control) { var row = new UIStackView { Axis = UILayoutConstraintAxis.Horizontal, Distribution = UIStackViewDistribution.FillEqually, Spacing = 8 }; var text = new UILabel { Text = label }; control.AccessibilityLabel = label; row.AddArrangedSubview(text); row.AddArrangedSubview(control); return row; }
|
||||
private static UIView SwitchRow(string label, UISwitch toggle) { var row = new UIStackView { Axis = UILayoutConstraintAxis.Horizontal, Distribution = UIStackViewDistribution.EqualSpacing }; row.AddArrangedSubview(new UILabel { Text = label }); toggle.AccessibilityLabel = label; row.AddArrangedSubview(toggle); return row; }
|
||||
private static UIMenu Choice(UIButton button, string[] values) => UIMenu.Create(values.Select(value => UIAction.Create(value, null, null, _ => button.SetTitle(value, UIControlState.Normal))).ToArray());
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>com.apple.security.application-groups</key><array><string>group.me.iamtalon.voicecat</string></array>
|
||||
</dict></plist>
|
||||
@@ -0,0 +1,49 @@
|
||||
using AVFoundation;
|
||||
using Foundation;
|
||||
|
||||
namespace VoiceCat.iOS;
|
||||
|
||||
internal enum SoundEvent { ChannelJoin, ChannelLeave, ChannelReceived, ChannelSent, PrivateReceived, PrivateSent, Login, Logout, ConnectionLost, VoiceOn, VoiceOff, VoiceStart, VoiceStop, PushToTalk }
|
||||
|
||||
internal sealed class EventFeedback : IDisposable
|
||||
{
|
||||
private readonly IosSettings settings;
|
||||
private readonly AVSpeechSynthesizer speech = new();
|
||||
private readonly Dictionary<SoundEvent, AVAudioPlayer> players = [];
|
||||
|
||||
internal EventFeedback(IosSettings settings) => this.settings = settings;
|
||||
|
||||
internal void Play(SoundEvent sound)
|
||||
{
|
||||
if (!settings.EventSounds || settings.EventVolume <= 0 ||
|
||||
(sound is SoundEvent.VoiceStart or SoundEvent.VoiceStop && !settings.SelfTalkSounds) ||
|
||||
(sound == SoundEvent.PushToTalk && !settings.PushToTalkSound)) return;
|
||||
if (!players.TryGetValue(sound, out AVAudioPlayer? player))
|
||||
{
|
||||
string path = Path.Combine(NSBundle.MainBundle.ResourcePath ?? "", "Sounds", FileName(sound) + ".wav");
|
||||
if (!File.Exists(path)) return;
|
||||
player = AVAudioPlayer.FromUrl(NSUrl.FromFilename(path));
|
||||
if (player is null) return;
|
||||
player.PrepareToPlay(); players[sound] = player;
|
||||
}
|
||||
player.Volume = settings.EventVolume; player.CurrentTime = 0; player.Play();
|
||||
}
|
||||
|
||||
internal void Speak(string text)
|
||||
{
|
||||
if (!settings.SpokenEvents || string.IsNullOrWhiteSpace(text)) return;
|
||||
speech.SpeakUtterance(new AVSpeechUtterance(text.Trim()));
|
||||
}
|
||||
|
||||
private static string FileName(SoundEvent value) => value switch
|
||||
{
|
||||
SoundEvent.ChannelJoin => "channel_join", SoundEvent.ChannelLeave => "channel_leave",
|
||||
SoundEvent.ChannelReceived => "channel_recv", SoundEvent.ChannelSent => "channel_sent",
|
||||
SoundEvent.PrivateReceived => "pm_recv", SoundEvent.PrivateSent => "pm_sent",
|
||||
SoundEvent.Login => "login", SoundEvent.Logout => "logout", SoundEvent.ConnectionLost => "connection_lost",
|
||||
SoundEvent.VoiceOn => "voice_on", SoundEvent.VoiceOff => "voice_off", SoundEvent.VoiceStart => "va_start",
|
||||
SoundEvent.VoiceStop => "va_stop", _ => "ptt"
|
||||
};
|
||||
|
||||
public void Dispose() { foreach (AVAudioPlayer player in players.Values) player.Dispose(); speech.Dispose(); }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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); } }
|
||||
}
|
||||
@@ -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=""$(MSBuildThisFileDirectory)build-broadcast-extension.sh" "$(Configuration)" "$(VoiceCatBroadcastSdk)" "$(VoiceCatBroadcastArch)" "$(VoiceCatBroadcastOutput)"" />
|
||||
</Target>
|
||||
<Target Name="VoiceCatBuildIosCaptureBridge" BeforeTargets="PrepareForBuild">
|
||||
<Exec Command=""$(MSBuildThisFileDirectory)build-screen-capture-bridge.sh" "$(VoiceCatBroadcastSdk)" "$(VoiceCatBroadcastArch)" "$(VoiceCatIosCaptureOutput)"" />
|
||||
</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
@@ -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
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
Executable
+53
@@ -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"
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
root="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
device="${VOICECAT_IOS_DEVICE:-}"
|
||||
configuration="${CONFIGURATION:-Debug}"
|
||||
build=true
|
||||
console=false
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--device) device="$2"; shift 2 ;;
|
||||
--configuration) configuration="$2"; shift 2 ;;
|
||||
--no-build) build=false; shift ;;
|
||||
--console) console=true; shift ;;
|
||||
--list) xcrun devicectl list devices; exit 0 ;;
|
||||
-h|--help)
|
||||
echo "usage: $0 --device NAME-OR-UDID [--configuration Debug|Release] [--no-build] [--console]"
|
||||
echo "use --list to show devices, or set VOICECAT_IOS_DEVICE"
|
||||
exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$device" ] || { echo "pass --device NAME-OR-UDID or set VOICECAT_IOS_DEVICE" >&2; exit 2; }
|
||||
app="$root/dist/ios-managed-device/VoiceCat.iOS.app"
|
||||
if $build; then "$root/clients/apple/build-ios-device.sh" --configuration "$configuration"; fi
|
||||
[ -d "$app" ] || { echo "managed device app not found at $app; run build-ios-device.sh first" >&2; exit 1; }
|
||||
|
||||
xcrun devicectl device install app --device "$device" "$app"
|
||||
if $console; then
|
||||
xcrun devicectl device process launch --device "$device" --terminate-existing --console me.iamtalon.voicecat
|
||||
else
|
||||
xcrun devicectl device process launch --device "$device" --terminate-existing me.iamtalon.voicecat
|
||||
fi
|
||||
@@ -1,608 +0,0 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000017 /* VoiceCatiOSApp.swift */; };
|
||||
BBBB00000000000000000031 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000018 /* AppState.swift */; };
|
||||
BBBB00000000000000000032 /* SessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000019 /* SessionState.swift */; };
|
||||
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001A /* AudioSessionManager.swift */; };
|
||||
BBBB00000000000000000034 /* ServerListStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001B /* ServerListStore.swift */; };
|
||||
BBBB00000000000000000035 /* SavedServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001C /* SavedServer.swift */; };
|
||||
BBBB00000000000000000037 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001E /* ServerListView.swift */; };
|
||||
BBBB00000000000000000038 /* AddServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001F /* AddServerView.swift */; };
|
||||
BBBB00000000000000000039 /* ServerIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000020 /* ServerIdentityView.swift */; };
|
||||
BBBB0000000000000000003A /* PasswordPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000021 /* PasswordPromptView.swift */; };
|
||||
BBBB0000000000000000003B /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000022 /* MainView.swift */; };
|
||||
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000023 /* ChannelTreeView.swift */; };
|
||||
BBBB0000000000000000003D /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000024 /* UserListView.swift */; };
|
||||
BBBB0000000000000000003E /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000025 /* ChatView.swift */; };
|
||||
BBBB00000000000000000070 /* ChannelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000060 /* ChannelBrowserView.swift */; };
|
||||
BBBB00000000000000000071 /* ChannelDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000061 /* ChannelDetailView.swift */; };
|
||||
BBBB00000000000000000072 /* UserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000062 /* UserRow.swift */; };
|
||||
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000027 /* VoiceControlsView.swift */; };
|
||||
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000028 /* PerUserTuningView.swift */; };
|
||||
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000029 /* ChannelEditView.swift */; };
|
||||
BBBB00000000000000000043 /* BanUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002A /* BanUserView.swift */; };
|
||||
BBBB00000000000000000044 /* MoveUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002B /* MoveUserView.swift */; };
|
||||
BBBB00000000000000000045 /* PermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002C /* PermissionsView.swift */; };
|
||||
BBBB00000000000000000046 /* AccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002D /* AccountsView.swift */; };
|
||||
BBBB00000000000000000047 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002E /* SettingsView.swift */; };
|
||||
BBBB0000000000000000004B /* IOSAudioRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002F /* IOSAudioRouter.swift */; };
|
||||
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */ = {isa = PBXBuildFile; productRef = BBBB0000000000000000004A /* VoiceCatCore */; };
|
||||
CCCC00000000000000000010 /* BroadcastAudioPump.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000002 /* BroadcastAudioPump.swift */; };
|
||||
CCCC00000000000000000011 /* BroadcastAudioRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000001 /* BroadcastAudioRing.swift */; };
|
||||
CCCC00000000000000000012 /* SampleHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000003 /* SampleHandler.swift */; };
|
||||
CCCC00000000000000000013 /* BroadcastAudioRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000001 /* BroadcastAudioRing.swift */; };
|
||||
CCCC00000000000000000014 /* VoiceCatBroadcast.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000006 /* VoiceCatBroadcast.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
CCCC00000000000000000036 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = BBBB00000000000000000001 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = CCCC00000000000000000030 /* VoiceCatBroadcast */;
|
||||
remoteInfo = VoiceCatBroadcast;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
CCCC00000000000000000035 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = CCCC00000000000000000030 /* VoiceCatBroadcast */;
|
||||
targetProxy = CCCC00000000000000000036 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
BBBB00000000000000000012 /* VoiceCatiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceCatiOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BBBB00000000000000000015 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatiOS.entitlements; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceCatiOSApp.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000018 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000019 /* SessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionState.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001A /* AudioSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionManager.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001B /* ServerListStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListStore.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001C /* SavedServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedServer.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001E /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001F /* AddServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000020 /* ServerIdentityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerIdentityView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000021 /* PasswordPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000022 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000023 /* ChannelTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTreeView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000024 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000025 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000060 /* ChannelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelBrowserView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000061 /* ChannelDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelDetailView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000062 /* UserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRow.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000027 /* VoiceControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceControlsView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000028 /* PerUserTuningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerUserTuningView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000029 /* ChannelEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002A /* BanUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanUserView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002B /* MoveUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveUserView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002C /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002D /* AccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002F /* IOSAudioRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSAudioRouter.swift; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000001 /* BroadcastAudioRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastAudioRing.swift; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000002 /* BroadcastAudioPump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastAudioPump.swift; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000003 /* SampleHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleHandler.swift; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000004 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000005 /* VoiceCatBroadcast.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatBroadcast.entitlements; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000006 /* VoiceCatBroadcast.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VoiceCatBroadcast.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
CCCC00000000000000000037 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
CCCC00000000000000000014 /* VoiceCatBroadcast.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
BBBB00000000000000000011 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
BBBB00000000000000000002 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BBBB00000000000000000003 /* VoiceCatiOS */,
|
||||
CCCC00000000000000000021 /* VoiceCatBroadcast */,
|
||||
CCCC00000000000000000020 /* Shared */,
|
||||
BBBB00000000000000000007 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BBBB00000000000000000003 /* VoiceCatiOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BBBB00000000000000000015 /* Info.plist */,
|
||||
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */,
|
||||
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */,
|
||||
BBBB00000000000000000018 /* AppState.swift */,
|
||||
BBBB00000000000000000019 /* SessionState.swift */,
|
||||
BBBB0000000000000000001A /* AudioSessionManager.swift */,
|
||||
BBBB0000000000000000002F /* IOSAudioRouter.swift */,
|
||||
BBBB0000000000000000001B /* ServerListStore.swift */,
|
||||
BBBB0000000000000000001C /* SavedServer.swift */,
|
||||
CCCC00000000000000000002 /* BroadcastAudioPump.swift */,
|
||||
BBBB00000000000000000006 /* Views */,
|
||||
);
|
||||
path = VoiceCatiOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CCCC00000000000000000020 /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CCCC00000000000000000001 /* BroadcastAudioRing.swift */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CCCC00000000000000000021 /* VoiceCatBroadcast */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CCCC00000000000000000003 /* SampleHandler.swift */,
|
||||
CCCC00000000000000000004 /* Info.plist */,
|
||||
CCCC00000000000000000005 /* VoiceCatBroadcast.entitlements */,
|
||||
);
|
||||
path = VoiceCatBroadcast;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BBBB00000000000000000006 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BBBB0000000000000000001E /* ServerListView.swift */,
|
||||
BBBB0000000000000000001F /* AddServerView.swift */,
|
||||
BBBB00000000000000000020 /* ServerIdentityView.swift */,
|
||||
BBBB00000000000000000021 /* PasswordPromptView.swift */,
|
||||
BBBB00000000000000000022 /* MainView.swift */,
|
||||
BBBB00000000000000000023 /* ChannelTreeView.swift */,
|
||||
BBBB00000000000000000024 /* UserListView.swift */,
|
||||
BBBB00000000000000000025 /* ChatView.swift */,
|
||||
BBBB00000000000000000060 /* ChannelBrowserView.swift */,
|
||||
BBBB00000000000000000061 /* ChannelDetailView.swift */,
|
||||
BBBB00000000000000000062 /* UserRow.swift */,
|
||||
BBBB00000000000000000027 /* VoiceControlsView.swift */,
|
||||
BBBB00000000000000000028 /* PerUserTuningView.swift */,
|
||||
BBBB00000000000000000029 /* ChannelEditView.swift */,
|
||||
BBBB0000000000000000002A /* BanUserView.swift */,
|
||||
BBBB0000000000000000002B /* MoveUserView.swift */,
|
||||
BBBB0000000000000000002C /* PermissionsView.swift */,
|
||||
BBBB0000000000000000002D /* AccountsView.swift */,
|
||||
BBBB0000000000000000002E /* SettingsView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BBBB00000000000000000007 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BBBB00000000000000000012 /* VoiceCatiOS.app */,
|
||||
CCCC00000000000000000006 /* VoiceCatBroadcast.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
BBBB00000000000000000008 /* VoiceCatiOS */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = BBBB0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatiOS" */;
|
||||
buildPhases = (
|
||||
BBBB0000000000000000000F /* Sources */,
|
||||
BBBB00000000000000000010 /* Resources */,
|
||||
BBBB00000000000000000011 /* Frameworks */,
|
||||
CCCC00000000000000000037 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
CCCC00000000000000000035 /* PBXTargetDependency */,
|
||||
);
|
||||
name = VoiceCatiOS;
|
||||
packageProductDependencies = (
|
||||
BBBB0000000000000000004A /* VoiceCatCore */,
|
||||
);
|
||||
productName = VoiceCatiOS;
|
||||
productReference = BBBB00000000000000000012 /* VoiceCatiOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
CCCC00000000000000000030 /* VoiceCatBroadcast */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CCCC00000000000000000032 /* Build configuration list for PBXNativeTarget "VoiceCatBroadcast" */;
|
||||
buildPhases = (
|
||||
CCCC00000000000000000031 /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = VoiceCatBroadcast;
|
||||
productName = VoiceCatBroadcast;
|
||||
productReference = CCCC00000000000000000006 /* VoiceCatBroadcast.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
BBBB00000000000000000001 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1500;
|
||||
LastUpgradeCheck = 1500;
|
||||
};
|
||||
buildConfigurationList = BBBB00000000000000000009 /* Build configuration list for PBXProject "VoiceCatiOS" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = BBBB00000000000000000002;
|
||||
packageReferences = (
|
||||
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */,
|
||||
);
|
||||
productRefGroup = BBBB00000000000000000007 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
BBBB00000000000000000008 /* VoiceCatiOS */,
|
||||
CCCC00000000000000000030 /* VoiceCatBroadcast */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
BBBB00000000000000000010 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
BBBB0000000000000000000F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */,
|
||||
BBBB00000000000000000031 /* AppState.swift in Sources */,
|
||||
BBBB00000000000000000032 /* SessionState.swift in Sources */,
|
||||
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */,
|
||||
BBBB0000000000000000004B /* IOSAudioRouter.swift in Sources */,
|
||||
BBBB00000000000000000034 /* ServerListStore.swift in Sources */,
|
||||
BBBB00000000000000000035 /* SavedServer.swift in Sources */,
|
||||
CCCC00000000000000000010 /* BroadcastAudioPump.swift in Sources */,
|
||||
CCCC00000000000000000011 /* BroadcastAudioRing.swift in Sources */,
|
||||
BBBB00000000000000000037 /* ServerListView.swift in Sources */,
|
||||
BBBB00000000000000000038 /* AddServerView.swift in Sources */,
|
||||
BBBB00000000000000000039 /* ServerIdentityView.swift in Sources */,
|
||||
BBBB0000000000000000003A /* PasswordPromptView.swift in Sources */,
|
||||
BBBB0000000000000000003B /* MainView.swift in Sources */,
|
||||
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */,
|
||||
BBBB0000000000000000003D /* UserListView.swift in Sources */,
|
||||
BBBB0000000000000000003E /* ChatView.swift in Sources */,
|
||||
BBBB00000000000000000070 /* ChannelBrowserView.swift in Sources */,
|
||||
BBBB00000000000000000071 /* ChannelDetailView.swift in Sources */,
|
||||
BBBB00000000000000000072 /* UserRow.swift in Sources */,
|
||||
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */,
|
||||
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */,
|
||||
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */,
|
||||
BBBB00000000000000000043 /* BanUserView.swift in Sources */,
|
||||
BBBB00000000000000000044 /* MoveUserView.swift in Sources */,
|
||||
BBBB00000000000000000045 /* PermissionsView.swift in Sources */,
|
||||
BBBB00000000000000000046 /* AccountsView.swift in Sources */,
|
||||
BBBB00000000000000000047 /* SettingsView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CCCC00000000000000000031 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CCCC00000000000000000012 /* SampleHandler.swift in Sources */,
|
||||
CCCC00000000000000000013 /* BroadcastAudioRing.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
BBBB0000000000000000000B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
BBBB0000000000000000000C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
BBBB0000000000000000000D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = VoiceCat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
BBBB0000000000000000000E /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = VoiceCat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
CCCC00000000000000000033 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatBroadcast/VoiceCatBroadcast.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = VoiceCatBroadcast/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS.broadcast;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
CCCC00000000000000000034 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatBroadcast/VoiceCatBroadcast.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = VoiceCatBroadcast/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS.broadcast;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
BBBB00000000000000000009 /* Build configuration list for PBXProject "VoiceCatiOS" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
BBBB0000000000000000000B /* Debug */,
|
||||
BBBB0000000000000000000C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
BBBB0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatiOS" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
BBBB0000000000000000000D /* Debug */,
|
||||
BBBB0000000000000000000E /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
CCCC00000000000000000032 /* Build configuration list for PBXNativeTarget "VoiceCatBroadcast" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CCCC00000000000000000033 /* Debug */,
|
||||
CCCC00000000000000000034 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = ../;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
BBBB0000000000000000004A /* VoiceCatCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */;
|
||||
productName = VoiceCatCore;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = BBBB00000000000000000001 /* Project object */;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BBBB00000000000000000008"
|
||||
BuildableName = "VoiceCatiOS.app"
|
||||
BlueprintName = "VoiceCatiOS"
|
||||
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BBBB00000000000000000008"
|
||||
BuildableName = "VoiceCatiOS.app"
|
||||
BlueprintName = "VoiceCatiOS"
|
||||
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BBBB00000000000000000008"
|
||||
BuildableName = "VoiceCatiOS.app"
|
||||
BlueprintName = "VoiceCatiOS"
|
||||
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -1,189 +0,0 @@
|
||||
import Foundation
|
||||
import VoiceCatCore
|
||||
|
||||
struct PendingIdentity: Identifiable {
|
||||
let id = UUID()
|
||||
let displayText: String
|
||||
let tofuStatus: VoiceCatTofuStatus
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AppState {
|
||||
var servers: [SavedServer] = ServerListStore.shared.load()
|
||||
var session: SessionState?
|
||||
|
||||
// Connect-flow state
|
||||
var isConnecting = false
|
||||
var connectStatus = ""
|
||||
var showAddServer = false
|
||||
var editingServer: SavedServer?
|
||||
var showPasswordPrompt = false
|
||||
var pendingIdentity: PendingIdentity?
|
||||
|
||||
private var connectingClient: VoiceCatClient?
|
||||
private(set) var connectingServer: SavedServer?
|
||||
private var identityHandled = false
|
||||
|
||||
// MARK: - Server list management
|
||||
|
||||
func addServer(_ server: SavedServer, password: String?) {
|
||||
if let pw = password, !pw.isEmpty {
|
||||
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
||||
}
|
||||
servers.append(server)
|
||||
ServerListStore.shared.save(servers)
|
||||
}
|
||||
|
||||
func updateServer(_ server: SavedServer, password: String?) {
|
||||
if let pw = password, !pw.isEmpty {
|
||||
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
||||
}
|
||||
if let idx = servers.firstIndex(where: { $0.id == server.id }) {
|
||||
servers[idx] = server
|
||||
}
|
||||
ServerListStore.shared.save(servers)
|
||||
}
|
||||
|
||||
func removeServer(_ server: SavedServer) {
|
||||
ServerListStore.shared.deletePassword(tag: server.keychainTag)
|
||||
servers.removeAll(where: { $0.id == server.id })
|
||||
ServerListStore.shared.save(servers)
|
||||
}
|
||||
|
||||
// MARK: - Connect flow
|
||||
|
||||
func connectTo(_ server: SavedServer) {
|
||||
guard !isConnecting else { return }
|
||||
isConnecting = true
|
||||
connectStatus = "Connecting…"
|
||||
connectingServer = server
|
||||
identityHandled = false
|
||||
|
||||
let config = VoiceCatConfig(
|
||||
clientName: "VoiceCat-iOS",
|
||||
clientVersion: "0.0.1",
|
||||
logLevel: .info,
|
||||
tofuStorePath: ServerListStore.shared.tofuStorePath)
|
||||
let client = VoiceCatClient(config: config)
|
||||
connectingClient = client
|
||||
|
||||
client.onEvent = { [weak self] ev in
|
||||
Task { @MainActor [weak self] in self?.handleConnectEvent(ev, server: server) }
|
||||
}
|
||||
client.connect(host: server.host, port: server.port)
|
||||
|
||||
// Auth is queued immediately — the core serialises it behind TLS + TOFU.
|
||||
switch server.authMode {
|
||||
case .guest:
|
||||
let nick = server.savedUsername.isEmpty ? "iOS User" : server.savedUsername
|
||||
client.authenticateGuest(nick)
|
||||
case .password:
|
||||
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)
|
||||
if let pw = savedPw, !pw.isEmpty {
|
||||
client.authenticateUser(server.savedUsername, password: pw)
|
||||
} else {
|
||||
showPasswordPrompt = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
session?.stopMicStream()
|
||||
session?.client.disconnect()
|
||||
AudioSessionManager.shared.deactivateSession()
|
||||
session = nil
|
||||
connectingClient?.disconnect()
|
||||
connectingClient = nil
|
||||
connectingServer = nil
|
||||
isConnecting = false
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
pendingIdentity = nil
|
||||
}
|
||||
|
||||
// MARK: - Auth actions (called from prompt sheets)
|
||||
|
||||
func authenticateUser(username: String, password: String) {
|
||||
connectingClient?.authenticateUser(username, password: password)
|
||||
showPasswordPrompt = false
|
||||
}
|
||||
|
||||
func confirmServerIdentity(accept: Bool) {
|
||||
connectingClient?.confirmServerIdentity(accept: accept)
|
||||
pendingIdentity = nil
|
||||
if !accept { cancelConnect() }
|
||||
}
|
||||
|
||||
func cancelConnect() {
|
||||
connectingClient?.disconnect()
|
||||
connectingClient = nil
|
||||
connectingServer = nil
|
||||
isConnecting = false
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
pendingIdentity = nil
|
||||
}
|
||||
|
||||
// MARK: - Connect event handler
|
||||
|
||||
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer) {
|
||||
switch ev.type {
|
||||
case .connectionState:
|
||||
switch ev.connectionState {
|
||||
case .connecting: connectStatus = "Connecting…"
|
||||
case .tlsHandshake: connectStatus = "TLS handshake…"
|
||||
case .authenticating: connectStatus = "Authenticating…"
|
||||
case .verifyingIdentity: connectStatus = "Verifying server identity…"
|
||||
case .connected: connectStatus = "Connected"
|
||||
default: break
|
||||
}
|
||||
case .serverIdentity:
|
||||
guard !identityHandled else { break }
|
||||
let tofuStatus = ev.tofuStatus ?? .firstConnect
|
||||
if tofuStatus == .matched {
|
||||
connectingClient?.confirmServerIdentity(accept: true)
|
||||
} else {
|
||||
identityHandled = true
|
||||
let displayText = connectingClient?.getServerIdentityDisplay() ?? ""
|
||||
pendingIdentity = PendingIdentity(displayText: displayText, tofuStatus: tofuStatus)
|
||||
}
|
||||
case .authResult:
|
||||
if ev.result == .ok {
|
||||
guard let client = connectingClient else { break }
|
||||
let perms = client.getPermissions()
|
||||
let newSession = SessionState(client: client, selfUserId: ev.userId, permissions: perms)
|
||||
connectingClient = nil
|
||||
isConnecting = false
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
self.session = newSession
|
||||
// Activate the audio session now, while connected — NOT lazily when the first
|
||||
// remote stream arrives. The core opens its miniaudio playback device the moment
|
||||
// a remote stream starts and only THEN emits .streamStarted; if we waited for
|
||||
// that event to activate, the playback device would open against an inactive
|
||||
// AVAudioSession and produce no sound (the "can't hear anyone" bug). Activating
|
||||
// here guarantees the session is live before any device opens.
|
||||
do {
|
||||
try AudioSessionManager.shared.ensureSessionActive()
|
||||
} catch {
|
||||
print("Audio session activate on connect failed: \(error)")
|
||||
}
|
||||
} else {
|
||||
connectStatus = "Auth failed: \(ev.result.description)"
|
||||
showPasswordPrompt = true
|
||||
}
|
||||
case .disconnected:
|
||||
if session == nil { cancelConnect() }
|
||||
else {
|
||||
AudioSessionManager.shared.deactivateSession()
|
||||
session = nil; isConnecting = false
|
||||
}
|
||||
case .error:
|
||||
connectStatus = ev.text ?? "Unknown error"
|
||||
if session == nil { isConnecting = false }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import AVFoundation
|
||||
import os
|
||||
import VoiceCatCore
|
||||
|
||||
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager")
|
||||
|
||||
@MainActor
|
||||
final class AudioSessionManager {
|
||||
static let shared = AudioSessionManager()
|
||||
|
||||
weak var client: VoiceCatClient?
|
||||
|
||||
/// The stream ID of the currently active local MIC stream, if any. Set by `SessionState`
|
||||
/// when the user joins/leaves voice so `IOSAudioRouter` can reset the core's capture
|
||||
/// channel count (e.g. when switching stereo → mono) without going through `SessionState`.
|
||||
var activeMicStreamId: UInt32?
|
||||
|
||||
/// Tracks whether WE activated the session. The session must be active whenever the
|
||||
/// AudioEngine is running (for capture OR playback), so it is activated when any audio
|
||||
/// needs to play (a remote stream started OR the user joins voice) and only deactivated
|
||||
/// when disconnecting from the server — not when leaving voice, since the user may still
|
||||
/// want to hear remote audio.
|
||||
private var isSessionActive = false
|
||||
|
||||
func configure() {
|
||||
// Load stored audio routing preferences and apply them before any audio session
|
||||
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
|
||||
// miniaudio (the core) does NOT touch AVAudioSession on iOS.
|
||||
IOSAudioRouter.shared.loadStoredPreferences()
|
||||
IOSAudioRouter.shared.applyConfiguration()
|
||||
IOSAudioRouter.shared.refreshRoutes()
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleInterruption),
|
||||
name: AVAudioSession.interruptionNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleRouteChange),
|
||||
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
/// Activate the AVAudioSession if not already active. Call before any audio I/O:
|
||||
/// when the user joins voice, or when a remote stream starts (so playback works even
|
||||
/// before the user has joined voice). Idempotent — safe to call multiple times.
|
||||
func ensureSessionActive() throws {
|
||||
guard !isSessionActive else {
|
||||
logger.debug("ensureSessionActive — already active, skipping")
|
||||
return
|
||||
}
|
||||
IOSAudioRouter.shared.applyConfiguration()
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setActive(true, options: [])
|
||||
isSessionActive = true
|
||||
// For the A2DP output presets, make sure output isn't pinned to the built-in speaker.
|
||||
// A2DP routing in .playAndRecord is fragile; clearing any speaker override after the
|
||||
// session is live nudges iOS to honor the Bluetooth output route.
|
||||
if IOSAudioRouter.shared.wantsA2dpOutput {
|
||||
try? session.overrideOutputAudioPort(.none)
|
||||
}
|
||||
let route = AVAudioSession.sharedInstance().currentRoute
|
||||
let outputNames = route.outputs.map { $0.portName }.joined(separator: ", ")
|
||||
let inputNames = route.inputs.map { $0.portName }.joined(separator: ", ")
|
||||
logger.info("session activated — outputs: [\(outputNames)], inputs: [\(inputNames)]")
|
||||
logSessionState("after activate")
|
||||
}
|
||||
|
||||
/// Deactivate the AVAudioSession. Call ONLY when disconnecting from the server — not
|
||||
/// when leaving voice (the user may still want to hear remote audio).
|
||||
func deactivateSession() {
|
||||
guard isSessionActive else {
|
||||
logger.debug("deactivateSession — not active, skipping")
|
||||
return
|
||||
}
|
||||
try? AVAudioSession.sharedInstance().setActive(false,
|
||||
options: .notifyOthersOnDeactivation)
|
||||
isSessionActive = false
|
||||
logger.info("session deactivated")
|
||||
}
|
||||
|
||||
/// Log the full AVAudioSession state — category, mode, options, and active route.
|
||||
/// Useful for diagnosing routing issues, e.g. confirming the session stays
|
||||
/// `PlayAndRecord` with `allowBluetoothA2DP` and keeps the A2DP output route even
|
||||
/// after the mic engine starts.
|
||||
func logSessionState(_ when: String) {
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
var opts: [String] = []
|
||||
let o = s.categoryOptions
|
||||
if o.contains(.mixWithOthers) { opts.append("mixWithOthers") }
|
||||
if o.contains(.duckOthers) { opts.append("duckOthers") }
|
||||
if o.contains(.allowBluetoothHFP) { opts.append("allowBluetoothHFP") }
|
||||
if o.contains(.allowBluetoothA2DP) { opts.append("allowBluetoothA2DP") }
|
||||
if o.contains(.allowAirPlay) { opts.append("allowAirPlay") }
|
||||
if o.contains(.defaultToSpeaker) { opts.append("defaultToSpeaker") }
|
||||
let outs = s.currentRoute.outputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
|
||||
.joined(separator: ", ")
|
||||
let ins = s.currentRoute.inputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
|
||||
.joined(separator: ", ")
|
||||
logger.info("""
|
||||
[SESSION @ \(when, privacy: .public)] category=\(s.category.rawValue, privacy: .public) \
|
||||
mode=\(s.mode.rawValue, privacy: .public) options=[\(opts.joined(separator: ","), privacy: .public)] \
|
||||
inputs=[\(ins, privacy: .public)] outputs=[\(outs, privacy: .public)] \
|
||||
inputCh=\(s.inputNumberOfChannels) outputCh=\(s.outputNumberOfChannels)
|
||||
""")
|
||||
}
|
||||
|
||||
@objc private func handleInterruption(_ notification: Notification) {
|
||||
guard let info = notification.userInfo,
|
||||
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
||||
else { return }
|
||||
|
||||
switch type {
|
||||
case .began:
|
||||
logger.info("interruption began — session suspended by system")
|
||||
isSessionActive = false // system deactivated us
|
||||
client?.audioSuspend()
|
||||
case .ended:
|
||||
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||
if options.contains(.shouldResume) {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
isSessionActive = true
|
||||
logger.info("interruption ended — session reactivated")
|
||||
client?.audioResume()
|
||||
} catch {
|
||||
logger.error("interruption ended — reactivation failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleRouteChange(_ notification: Notification) {
|
||||
guard let info = notification.userInfo,
|
||||
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
||||
else {
|
||||
logger.warning("routeChange — unknown reason, refreshing only")
|
||||
IOSAudioRouter.shared.refreshRoutes()
|
||||
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("routeChange reason=\(self.reasonLabel(reason))")
|
||||
|
||||
// Re-apply preferences ONLY on external device plug/unplug. Do NOT re-apply on
|
||||
// .categoryChange / .routeConfigurationChange — those are triggered by our own
|
||||
// applyConfiguration() calls (setCategory, setPreferredInput, etc.), and re-applying
|
||||
// would create an infinite notification loop:
|
||||
// handleRouteChange → applyConfiguration → setCategory → routeChange → ...
|
||||
// That loop burns CPU and cycles the audio session on/off — the "glitching" bug.
|
||||
// IOSAudioRouter.applyConfiguration() also has a re-entrancy guard for synchronous
|
||||
// notifications, but the reason check here is the primary defense.
|
||||
if reason == .oldDeviceUnavailable || reason == .newDeviceAvailable {
|
||||
logger.info("routeChange — external device change, re-applying config")
|
||||
IOSAudioRouter.shared.applyConfiguration()
|
||||
}
|
||||
|
||||
IOSAudioRouter.shared.refreshRoutes()
|
||||
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||
logSessionState("route change (\(reasonLabel(reason)))")
|
||||
}
|
||||
|
||||
private func reasonLabel(_ reason: AVAudioSession.RouteChangeReason) -> String {
|
||||
switch reason {
|
||||
case .oldDeviceUnavailable: return "oldDeviceUnavailable"
|
||||
case .newDeviceAvailable: return "newDeviceAvailable"
|
||||
case .categoryChange: return "categoryChange"
|
||||
case .override: return "override"
|
||||
case .wakeFromSleep: return "wakeFromSleep"
|
||||
case .noSuitableRouteForCategory: return "noSuitableRouteForCategory"
|
||||
case .routeConfigurationChange: return "routeConfigurationChange"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged")
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// BroadcastAudioPump — host side of iOS screen-audio sharing (docs/voice.md §9, iOS detail).
|
||||
//
|
||||
// Drains the App Group shared-memory ring written by the broadcast upload extension and hands
|
||||
// whole 20 ms frames to a feed closure (which calls VoiceCatClient.feedPcm). The host app owns
|
||||
// the SCREEN_AUDIO stream, so screen audio appears as a second stream of the SAME user — exactly
|
||||
// like the Windows/macOS desktop-audio share, and a single session (no separate connection).
|
||||
//
|
||||
// It reacts to the extension's Darwin notifications for prompt start/stop, and the drain timer
|
||||
// also watches the ring's active flag as a safety net if a notification is missed. The ring
|
||||
// always carries stereo; we downmix to mono when the stream's effective config is mono.
|
||||
//
|
||||
// Not @MainActor: the drain runs on a background queue (feedPcm is thread-safe). The start/stop
|
||||
// callbacks are dispatched to main so they can drive @MainActor SessionState.
|
||||
final class BroadcastAudioPump {
|
||||
|
||||
/// The host should start the SCREEN_AUDIO stream (a broadcast became active).
|
||||
var onBroadcastStarted: (() -> Void)?
|
||||
/// The host should stop the SCREEN_AUDIO stream (the broadcast ended).
|
||||
var onBroadcastFinished: (() -> Void)?
|
||||
|
||||
private static let frameSamplesPerChannel = 960 // 20 ms @ 48 kHz
|
||||
|
||||
private let queue = DispatchQueue(label: "cat.voice.broadcast.pump")
|
||||
private var ring: BroadcastAudioRing?
|
||||
private var timer: DispatchSourceTimer?
|
||||
private var feed: ((UnsafePointer<Int16>, Int, UInt32) -> Void)?
|
||||
private var streamChannels = 1
|
||||
private var ringChannels = 2
|
||||
private var pending: [Int16] = [] // interleaved at ringChannels width
|
||||
private var scratch = [Int16](repeating: 0, count: 8192)
|
||||
private var started = false
|
||||
|
||||
// MARK: - Lifecycle (host connect/disconnect)
|
||||
|
||||
func start() {
|
||||
guard !started else { return }
|
||||
ring = try? BroadcastAudioRing()
|
||||
started = true
|
||||
registerDarwin()
|
||||
// Host reconnected while a broadcast is still running — pick it up.
|
||||
if ring?.isActive == true { onBroadcastStarted?() }
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard started else { return }
|
||||
started = false
|
||||
unregisterDarwin()
|
||||
endFeeding()
|
||||
ring = nil
|
||||
}
|
||||
|
||||
// MARK: - Feeding (driven by the host once the stream is live)
|
||||
|
||||
/// Begin draining the ring into `feed`. Called after the SCREEN_AUDIO stream's
|
||||
/// `.streamStarted` event, when its effective channel count is known.
|
||||
func beginFeeding(streamChannels: UInt32,
|
||||
feed: @escaping (UnsafePointer<Int16>, Int, UInt32) -> Void) {
|
||||
queue.async {
|
||||
self.streamChannels = max(1, min(2, Int(streamChannels)))
|
||||
self.ringChannels = max(1, Int(self.ring?.channels ?? 2))
|
||||
self.feed = feed
|
||||
self.pending.removeAll(keepingCapacity: true)
|
||||
self.ring?.drainStale() // discard pre-roll buffered before we were ready
|
||||
self.startTimer()
|
||||
}
|
||||
}
|
||||
|
||||
func endFeeding() {
|
||||
queue.async {
|
||||
self.timer?.cancel()
|
||||
self.timer = nil
|
||||
self.feed = nil
|
||||
self.pending.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drain
|
||||
|
||||
private func startTimer() {
|
||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||
t.schedule(deadline: .now(), repeating: .milliseconds(10), leeway: .milliseconds(2))
|
||||
t.setEventHandler { [weak self] in self?.drain() }
|
||||
timer = t
|
||||
t.resume()
|
||||
}
|
||||
|
||||
private func drain() {
|
||||
guard let ring, let feed else { return }
|
||||
// Safety net: broadcast ended but we missed the Darwin note.
|
||||
if !ring.isActive {
|
||||
DispatchQueue.main.async { [weak self] in self?.onBroadcastFinished?() }
|
||||
return
|
||||
}
|
||||
while true {
|
||||
let got = scratch.withUnsafeMutableBufferPointer { ring.read(into: $0) }
|
||||
if got == 0 { break }
|
||||
pending.append(contentsOf: scratch[0..<got])
|
||||
if got < scratch.count { break }
|
||||
}
|
||||
emitFrames(feed)
|
||||
}
|
||||
|
||||
private func emitFrames(_ feed: (UnsafePointer<Int16>, Int, UInt32) -> Void) {
|
||||
let n = Self.frameSamplesPerChannel
|
||||
let rc = ringChannels
|
||||
let sc = streamChannels
|
||||
let inFrame = n * rc
|
||||
while pending.count >= inFrame {
|
||||
if sc == rc {
|
||||
pending.withUnsafeBufferPointer { feed($0.baseAddress!, n, UInt32(sc)) }
|
||||
} else if sc == 1 && rc == 2 {
|
||||
var mono = [Int16](repeating: 0, count: n)
|
||||
pending.withUnsafeBufferPointer { buf in
|
||||
let p = buf.baseAddress!
|
||||
for i in 0..<n { mono[i] = Int16((Int(p[i * 2]) + Int(p[i * 2 + 1])) / 2) }
|
||||
}
|
||||
mono.withUnsafeBufferPointer { feed($0.baseAddress!, n, 1) }
|
||||
} else if sc == 2 && rc == 1 {
|
||||
var stereo = [Int16](repeating: 0, count: n * 2)
|
||||
pending.withUnsafeBufferPointer { buf in
|
||||
let p = buf.baseAddress!
|
||||
for i in 0..<n { stereo[i * 2] = p[i]; stereo[i * 2 + 1] = p[i] }
|
||||
}
|
||||
stereo.withUnsafeBufferPointer { feed($0.baseAddress!, n, 2) }
|
||||
}
|
||||
pending.removeFirst(inFrame)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Darwin notifications
|
||||
|
||||
private func registerDarwin() {
|
||||
let observer = Unmanaged.passUnretained(self).toOpaque()
|
||||
let center = CFNotificationCenterGetDarwinNotifyCenter()
|
||||
let callback: CFNotificationCallback = { _, observer, name, _, _ in
|
||||
guard let observer, let name else { return }
|
||||
let pump = Unmanaged<BroadcastAudioPump>.fromOpaque(observer).takeUnretainedValue()
|
||||
let raw = name.rawValue as String
|
||||
DispatchQueue.main.async {
|
||||
if raw == BroadcastNotification.started { pump.onBroadcastStarted?() }
|
||||
else if raw == BroadcastNotification.finished { pump.onBroadcastFinished?() }
|
||||
}
|
||||
}
|
||||
CFNotificationCenterAddObserver(center, observer, callback,
|
||||
BroadcastNotification.started as CFString, nil, .deliverImmediately)
|
||||
CFNotificationCenterAddObserver(center, observer, callback,
|
||||
BroadcastNotification.finished as CFString, nil, .deliverImmediately)
|
||||
}
|
||||
|
||||
private func unregisterDarwin() {
|
||||
CFNotificationCenterRemoveEveryObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
Unmanaged.passUnretained(self).toOpaque())
|
||||
}
|
||||
|
||||
deinit { if started { unregisterDarwin() } }
|
||||
}
|
||||
@@ -1,663 +0,0 @@
|
||||
import AVFoundation
|
||||
import os
|
||||
import VoiceCatCore
|
||||
|
||||
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter")
|
||||
|
||||
/// iOS audio routing layer — drives all iOS audio route selection via `AVAudioSession`
|
||||
/// *before* the core (miniaudio) opens its device. This class is the sole owner of the
|
||||
/// session: miniaudio does NOT touch `AVAudioSession` on iOS, because the core opens its
|
||||
/// devices through a `ma_context` configured with `sessionCategory = none` +
|
||||
/// `noAudioSessionActivate/Deactivate` (see `AudioEngine::make_context_config` in
|
||||
/// `core/src/audio/audio_engine.cpp`). Without that, miniaudio's default path resets the
|
||||
/// category to `Record`/`Playback` with no options on every device open, wiping
|
||||
/// `.allowBluetoothA2DP`/`.playAndRecord` and killing headphone/A2DP output — so that
|
||||
/// config must stay in place. All iOS audio routing (input port selection, mic
|
||||
/// orientation/polar patterns, HFP vs A2DP, measurement/raw mode, stereo capture) must be
|
||||
/// driven from here.
|
||||
///
|
||||
/// The three user-facing choices:
|
||||
/// 1. **Input port** — which physical input (built-in mic, Bluetooth HFP, headset,
|
||||
/// USB, AirPlay). For the built-in mic, a sub-selection of **data source**
|
||||
/// (orientation: front/back/top/bottom) and **polar pattern**
|
||||
/// (omni/cardioid/subcardioid/bidirectional).
|
||||
/// 2. **Bluetooth mode** — how Bluetooth headsets are handled:
|
||||
/// - "BT HFP voice" (`.allowBluetoothHFP` + `.allowBluetoothA2DP`): both profiles
|
||||
/// allowed, iOS picks HFP for two-way mic or A2DP for output-only. Mono, AEC on.
|
||||
/// - "Built-in Mic + BT A2DP stereo" (`.allowBluetoothA2DP` only): stereo output,
|
||||
/// built-in mic, no HFP processing.
|
||||
/// - "Built-in Mic + Speaker" (neither): no Bluetooth at all.
|
||||
/// 3. **Mic processing mode** — Standard (`.voiceChat`: AEC/AGC/HPF on) or
|
||||
/// Raw/Studio (`.measurement`: all processing off). Raw mode is allowed always
|
||||
/// but shows a warning when the output route is the speaker (echo risk, no AEC).
|
||||
///
|
||||
/// Additionally, **stereo capture** (2-channel built-in mic) is enabled by switching the
|
||||
/// built-in mic's data source to the `.stereo` polar pattern. The recipe is:
|
||||
/// `setPreferredDataSource(.stereo source)` + `setPreferredPolarPattern(.stereo)` +
|
||||
/// `setPreferredInput(built-in mic)` + `setInputDataSource(stereo source)`. The channel
|
||||
/// count itself must NOT be requested via `setPreferredInputNumberOfChannels(2)` — that
|
||||
/// session-level call collapses the A2DP output route. Instead the core is told to open the
|
||||
/// device with 2 channels via `vc_set_capture_channels(streamId, 2)`, and the AVAudioSession
|
||||
/// input anchor (`setPreferredInput` + `setInputDataSource`) keeps the route stable during
|
||||
/// the HFP→A2DP and mono→stereo reconfigurations.
|
||||
///
|
||||
/// Voice Isolation / Wide Spectrum (iOS 17+/18+) are user-toggleable in Control Center
|
||||
/// for `.voiceChat` apps — surfaced as a hint, not a programmatic toggle.
|
||||
///
|
||||
/// All choices are persisted in `UserDefaults` and re-applied on route changes.
|
||||
@MainActor
|
||||
final class IOSAudioRouter: ObservableObject {
|
||||
|
||||
static let shared = IOSAudioRouter()
|
||||
|
||||
// MARK: - Published state (drives SettingsView)
|
||||
|
||||
@Published var inputPorts: [IOSAudioInputPort] = []
|
||||
@Published var outputRoutes: [IOSAudioOutputRoute] = []
|
||||
@Published var bluetoothMode: BluetoothMode = .btHfpVoice
|
||||
@Published var micMode: MicMode = .standard
|
||||
@Published var captureChannels: CaptureChannels = .mono
|
||||
@Published var selectedInputPortId: String?
|
||||
@Published var selectedDataSourceId: String?
|
||||
@Published var selectedPolarPattern: String?
|
||||
@Published var showsRawModeSpeakerWarning: Bool = false
|
||||
@Published var showsA2dpNoAecWarning: Bool = false
|
||||
@Published var hasBluetoothDevice: Bool = false
|
||||
@Published var hasWiredHeadset: Bool = false
|
||||
|
||||
/// Audio presets — sensible combinations of settings for common scenarios.
|
||||
/// The app is about choice: users can pick a preset for a quick start, then
|
||||
/// fine-tune individual settings under "Advanced Audio".
|
||||
enum AudioPreset: String, CaseIterable, Identifiable {
|
||||
/// Standard iOS VoIP experience: AEC/AGC/HPF on, mono, system picks best route
|
||||
/// (BT HFP if connected, wired if connected, speaker if nothing). Always available.
|
||||
case voiceChat = "Voice Chat"
|
||||
/// Stereo built-in mic capture (front+back capsules). A2DP output if BT is connected,
|
||||
/// else built-in speaker / wired. Standard processing (no AEC — stereo needs a non-VPIO
|
||||
/// mode). Always available.
|
||||
case stereoMic = "Stereo Mic"
|
||||
/// Maximum fidelity: stereo mic, no AEC/AGC/HPF (raw mode). A2DP output if BT connected,
|
||||
/// else speaker/wired. Always available. Echo risk on speaker.
|
||||
case studio = "Studio (No Processing)"
|
||||
/// Bluetooth HFP: BT mic + BT output, AEC on, mono. Only when BT is connected.
|
||||
case bluetoothHeadset = "Bluetooth Headset (HFP)"
|
||||
/// A2DP stereo output + built-in mono mic, AEC off. Only when BT is connected. (For
|
||||
/// A2DP output + stereo mic, use the Stereo Mic preset while BT is connected.)
|
||||
case btHeadphonesMonoMic = "BT Headphones + Mono Mic"
|
||||
/// Wired headset/earpods: wired output + wired mic (or built-in), AEC on, mono.
|
||||
/// Only when a wired audio device is connected.
|
||||
case wiredHeadset = "Wired Headset"
|
||||
/// Settings don't match any preset — user has tweaked advanced controls.
|
||||
case custom = "Custom"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var requiresBluetooth: Bool {
|
||||
switch self {
|
||||
case .bluetoothHeadset, .btHeadphonesMonoMic: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var requiresWired: Bool {
|
||||
self == .wiredHeadset
|
||||
}
|
||||
|
||||
var bluetoothMode: BluetoothMode {
|
||||
switch self {
|
||||
case .voiceChat, .bluetoothHeadset: return .btHfpVoice
|
||||
// A2DP output when BT is connected; falls back to speaker/wired when it isn't.
|
||||
case .stereoMic, .studio, .btHeadphonesMonoMic: return .builtInMicBtA2dp
|
||||
case .wiredHeadset: return .builtInMicSpeaker
|
||||
case .custom: return .builtInMicSpeaker // placeholder
|
||||
}
|
||||
}
|
||||
|
||||
var captureChannels: CaptureChannels {
|
||||
switch self {
|
||||
case .stereoMic, .studio: return .stereo
|
||||
default: return .mono
|
||||
}
|
||||
}
|
||||
|
||||
var micMode: MicMode {
|
||||
switch self {
|
||||
case .studio: return .raw
|
||||
default: return .standard
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this preset explicitly selects the built-in mic port.
|
||||
var usesBuiltInMic: Bool {
|
||||
switch self {
|
||||
case .stereoMic, .studio, .btHeadphonesMonoMic: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BluetoothMode: String, CaseIterable, Identifiable {
|
||||
case btHfpVoice = "BT HFP Voice"
|
||||
case builtInMicBtA2dp = "Built-in Mic + BT A2DP"
|
||||
case builtInMicSpeaker = "Built-in Mic + Speaker"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum MicMode: String, CaseIterable, Identifiable {
|
||||
case standard = "Standard"
|
||||
case raw = "Raw / Studio"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum CaptureChannels: String, CaseIterable, Identifiable {
|
||||
case mono = "Mono"
|
||||
case stereo = "Stereo"
|
||||
var id: String { rawValue }
|
||||
var channelCount: UInt32 { self == .stereo ? 2 : 1 }
|
||||
}
|
||||
|
||||
// MARK: - UserDefaults keys
|
||||
|
||||
private let kBluetoothMode = "cat.voice.audio.bluetoothMode"
|
||||
private let kMicMode = "cat.voice.audio.micMode"
|
||||
private let kCaptureChannels = "cat.voice.audio.captureChannels"
|
||||
private let kInputPortId = "cat.voice.audio.inputPortId"
|
||||
private let kDataSourceId = "cat.voice.audio.dataSourceId"
|
||||
private let kPolarPattern = "cat.voice.audio.polarPattern"
|
||||
private let kPreset = "cat.voice.audio.preset"
|
||||
|
||||
/// Re-entrancy guard: setCategory/setPreferredInput/etc. trigger route-change
|
||||
/// notifications synchronously on the same thread. Without this guard,
|
||||
/// handleRouteChange → applyConfiguration → setCategory → route-change notification
|
||||
/// → handleRouteChange → applyConfiguration → ... creates an infinite loop that
|
||||
/// burns CPU and cycles the audio session on/off (the "glitching" bug).
|
||||
private var isApplyingConfiguration = false
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Load / refresh from AVAudioSession
|
||||
|
||||
/// Refresh the published input port list and output route list from the current
|
||||
/// AVAudioSession state. Call after any route change or when the settings view appears.
|
||||
func refreshRoutes() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let currentInput = session.preferredInput
|
||||
let currentDataSource = currentInput?.preferredDataSource?.dataSourceID ?? nil
|
||||
let currentPolarPattern = currentInput?.preferredDataSource?.preferredPolarPattern?.rawValue
|
||||
|
||||
inputPorts = (session.availableInputs ?? []).map { port in
|
||||
let dataSources = port.dataSources?.map { ds in
|
||||
IOSAudioDataSource(
|
||||
id: String(describing: ds.dataSourceID),
|
||||
name: ds.dataSourceName,
|
||||
polarPatterns: ds.supportedPolarPatterns?.map { $0.rawValue },
|
||||
isSelected: currentDataSource == ds.dataSourceID,
|
||||
selectedPolarPattern: currentPolarPattern
|
||||
)
|
||||
}
|
||||
return IOSAudioInputPort(
|
||||
id: port.uid,
|
||||
name: port.portName,
|
||||
portType: port.portType.rawValue,
|
||||
dataSources: dataSources,
|
||||
isSelected: currentInput?.uid == port.uid
|
||||
)
|
||||
}
|
||||
|
||||
outputRoutes = session.currentRoute.outputs.map { port in
|
||||
IOSAudioOutputRoute(
|
||||
id: port.uid,
|
||||
name: port.portName,
|
||||
portType: port.portType.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
if selectedInputPortId == nil {
|
||||
selectedInputPortId = currentInput?.uid ?? inputPorts.first?.id
|
||||
}
|
||||
if selectedDataSourceId == nil {
|
||||
selectedDataSourceId = currentDataSource.map { String(describing: $0) }
|
||||
}
|
||||
if selectedPolarPattern == nil {
|
||||
selectedPolarPattern = currentPolarPattern
|
||||
}
|
||||
|
||||
updateWarnings()
|
||||
detectAudioDevices()
|
||||
}
|
||||
|
||||
/// Detect connected audio devices — Bluetooth (A2DP/HFP) and wired (headphones,
|
||||
/// headset mic, USB audio). Drives which presets are shown: BT presets only appear
|
||||
/// when a BT device is connected, wired presets only when a wired device is connected.
|
||||
/// This avoids confusing users with irrelevant options.
|
||||
private func detectAudioDevices() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let route = session.currentRoute
|
||||
let inputs = session.availableInputs ?? []
|
||||
|
||||
// Bluetooth: check current route + available inputs
|
||||
let hasBTOutput = route.outputs.contains {
|
||||
$0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP
|
||||
}
|
||||
let hasBTInput = route.inputs.contains { $0.portType == .bluetoothHFP }
|
||||
let hasBTAvailable = inputs.contains {
|
||||
$0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP
|
||||
}
|
||||
let wasBT = hasBluetoothDevice
|
||||
hasBluetoothDevice = hasBTOutput || hasBTInput || hasBTAvailable
|
||||
if hasBluetoothDevice != wasBT {
|
||||
logger.info("bluetooth device \(self.hasBluetoothDevice ? "connected" : "disconnected")")
|
||||
}
|
||||
|
||||
// Wired: headphones, headset mic, USB audio (earpods, Lightning/USB-C headsets)
|
||||
let hasWiredOutput = route.outputs.contains {
|
||||
$0.portType == .headphones || $0.portType == .usbAudio
|
||||
}
|
||||
let hasWiredInput = route.inputs.contains {
|
||||
$0.portType == .headsetMic || $0.portType == .usbAudio
|
||||
}
|
||||
let hasWiredAvailable = inputs.contains {
|
||||
$0.portType == .headphones || $0.portType == .headsetMic || $0.portType == .usbAudio
|
||||
}
|
||||
let wasWired = hasWiredHeadset
|
||||
hasWiredHeadset = hasWiredOutput || hasWiredInput || hasWiredAvailable
|
||||
if hasWiredHeadset != wasWired {
|
||||
logger.info("wired headset \(self.hasWiredHeadset ? "connected" : "disconnected")")
|
||||
}
|
||||
}
|
||||
|
||||
/// The presets available given the current device connection state.
|
||||
/// Always includes Voice Chat, Stereo Mic, Studio, and Custom. BT presets only when
|
||||
/// a Bluetooth device is connected. Wired preset only when a wired device is connected.
|
||||
var availablePresets: [AudioPreset] {
|
||||
AudioPreset.allCases.filter { preset in
|
||||
if preset == .custom { return true }
|
||||
if preset.requiresBluetooth && !hasBluetoothDevice { return false }
|
||||
if preset.requiresWired && !hasWiredHeadset { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Which preset matches the current settings, or .custom if nothing matches.
|
||||
/// Checks device-specific presets first (BT, wired) so that e.g. when BT is connected
|
||||
/// and settings match "Bluetooth Headset", it returns that instead of the equivalent
|
||||
/// "Voice Chat" (which has the same bluetoothMode/micMode/channels but is more general).
|
||||
var activePreset: AudioPreset {
|
||||
// Check device-specific presets first (most specific → least specific)
|
||||
let order: [AudioPreset] = [
|
||||
.bluetoothHeadset, .btHeadphonesMonoMic,
|
||||
.wiredHeadset,
|
||||
.voiceChat, .stereoMic, .studio,
|
||||
]
|
||||
for preset in order {
|
||||
if bluetoothMode == preset.bluetoothMode
|
||||
&& captureChannels == preset.captureChannels
|
||||
&& micMode == preset.micMode {
|
||||
// Don't match a BT preset if no BT is connected — fall through to Voice Chat
|
||||
if preset.requiresBluetooth && !hasBluetoothDevice { continue }
|
||||
if preset.requiresWired && !hasWiredHeadset { continue }
|
||||
return preset
|
||||
}
|
||||
}
|
||||
return .custom
|
||||
}
|
||||
|
||||
// MARK: - Apply configuration
|
||||
|
||||
/// Apply the full audio configuration to AVAudioSession. Call this before the core
|
||||
/// opens its capture device (i.e. before `startMicStream` → `activateForStreaming`).
|
||||
/// Re-entrant-safe: if a route-change notification fires synchronously during a
|
||||
/// `setCategory`/`setPreferredInput` call, the guard prevents re-entry.
|
||||
func applyConfiguration() {
|
||||
guard !isApplyingConfiguration else {
|
||||
logger.debug("applyConfiguration skipped — already applying (re-entrancy guard)")
|
||||
return
|
||||
}
|
||||
isApplyingConfiguration = true
|
||||
defer { isApplyingConfiguration = false }
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
|
||||
// 1. Build category options from bluetooth mode.
|
||||
// .mixWithOthers is ALWAYS set — it keeps other audio (notably VoiceOver, which a
|
||||
// blind user needs to operate the phone) audible while our session is active. Never
|
||||
// drop it.
|
||||
// .defaultToSpeaker is set ONLY for the speaker preset. It forces output to the
|
||||
// built-in speaker instead of the receiver — but it also actively breaks A2DP
|
||||
// routing in .playAndRecord, so it must NOT be set for the A2DP or HFP presets.
|
||||
// .allowAirPlay is added to the Bluetooth presets so AirPlay output also works.
|
||||
var options: AVAudioSession.CategoryOptions = [.mixWithOthers]
|
||||
switch bluetoothMode {
|
||||
case .btHfpVoice:
|
||||
// Voice Chat: allow BOTH HFP and A2DP, let iOS pick the right profile for the
|
||||
// connected device. HFP and A2DP must NOT be made mutually exclusive (HFP-only)
|
||||
// — that blocks A2DP headphones from receiving audio. HFP is preferred (the system
|
||||
// uses it when a two-way mic path is needed); A2DP stays available for output-only.
|
||||
options.insert(.allowBluetoothHFP)
|
||||
options.insert(.allowBluetoothA2DP)
|
||||
options.insert(.allowAirPlay)
|
||||
case .builtInMicBtA2dp:
|
||||
// A2DP output only (no HFP). With HFP disabled the Bluetooth device can only be
|
||||
// an OUTPUT (A2DP), so the system routes the mic to the built-in mic — exactly
|
||||
// what we want for "built-in mic + A2DP output", in either mono OR stereo.
|
||||
options.insert(.allowBluetoothA2DP)
|
||||
options.insert(.allowAirPlay)
|
||||
case .builtInMicSpeaker:
|
||||
// Built-in mic + speaker/wired output only. Prefer speaker over the receiver.
|
||||
options.insert(.defaultToSpeaker)
|
||||
}
|
||||
|
||||
// 2. Set category + mode, chosen per scenario:
|
||||
// - Stereo capture: .default — .voiceChat (the AEC/VPIO path) forces MONO, so stereo
|
||||
// is only possible in a non-VPIO mode. .default supports multi-capsule stereo AND
|
||||
// keeps the A2DP output route alive.
|
||||
// - Mono raw/studio: .measurement — all system processing off.
|
||||
// - Mono + A2DP output: .videoRecording — keeps A2DP output without VPIO (no AEC).
|
||||
// - Mono standard (HFP or speaker): .voiceChat — hardware AEC/AGC/HPF.
|
||||
let mode: AVAudioSession.Mode
|
||||
if captureChannels == .stereo {
|
||||
mode = .default
|
||||
} else if micMode == .raw {
|
||||
mode = .measurement
|
||||
} else if bluetoothMode == .builtInMicBtA2dp {
|
||||
mode = .videoRecording
|
||||
} else {
|
||||
mode = .voiceChat
|
||||
}
|
||||
|
||||
do {
|
||||
try session.setCategory(.playAndRecord, mode: mode, options: options)
|
||||
logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue), ch=\(self.captureChannels.rawValue), options=\(self.optionsLabel(options))")
|
||||
} catch {
|
||||
logger.error("setCategory failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// 3. Input & mic-capsule configuration.
|
||||
if captureChannels == .stereo {
|
||||
// Stereo: enable the built-in mic's .stereo polar pattern AND anchor the input
|
||||
// route explicitly via setPreferredInput + setInputDataSource. With HFP disabled
|
||||
// the system routes input to the built-in mic, but without the explicit
|
||||
// preferred-input anchor the route can collapse during the mode switch
|
||||
// (.voiceChat → .default) and the output dies. The channel count is requested by
|
||||
// miniaudio at the audio-unit level (vc_set_capture_channels), NOT via
|
||||
// setPreferredInputNumberOfChannels(2) — that call collapses the A2DP output route.
|
||||
configureStereoCapture(session: session)
|
||||
} else if let portId = selectedInputPortId, !portId.isEmpty,
|
||||
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
||||
// Mono with an explicit input-port selection (advanced settings).
|
||||
do {
|
||||
try session.setPreferredInput(port)
|
||||
logger.info("setPreferredInput ok — \(port.portName)")
|
||||
} catch {
|
||||
logger.error("setPreferredInput failed: \(error.localizedDescription)")
|
||||
}
|
||||
configureMonoCapture(session: session, port: port)
|
||||
} else {
|
||||
// Mono, system-default input. Still clear any leftover .stereo capsule from a
|
||||
// prior stereo session so we actually return to mono.
|
||||
clearStereoPolarPattern(session: session)
|
||||
}
|
||||
|
||||
updateWarnings()
|
||||
}
|
||||
|
||||
/// Enable 2-channel capture on the built-in mic. The recipe that achieves stereo mic +
|
||||
/// A2DP Bluetooth output simultaneously:
|
||||
/// 1. `setPreferredDataSource(stereoSource)` on the built-in mic port
|
||||
/// 2. `setPreferredPolarPattern(.stereo)` on that data source
|
||||
/// 3. `setPreferredInput(builtIn)` — anchor the input route explicitly. Without this
|
||||
/// anchor the route can collapse during the mode switch (.voiceChat → .default).
|
||||
/// 4. `setInputDataSource(stereoSource)` — commit the data source at the session level
|
||||
/// The channel count itself is requested by miniaudio at the audio-unit level via
|
||||
/// `vc_set_capture_channels(2)`. We must NOT call `setPreferredInputNumberOfChannels(2)`
|
||||
/// — that session-level call collapses the A2DP output route.
|
||||
private func configureStereoCapture(session: AVAudioSession) {
|
||||
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
|
||||
else {
|
||||
logger.warning("stereo requested but no built-in mic available — staying mono")
|
||||
return
|
||||
}
|
||||
guard let stereoSource = builtIn.dataSources?.first(where: {
|
||||
$0.supportedPolarPatterns?.contains(.stereo) == true
|
||||
}) else {
|
||||
logger.warning("stereo requested but built-in mic has no .stereo data source — staying mono")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try builtIn.setPreferredDataSource(stereoSource)
|
||||
try stereoSource.setPreferredPolarPattern(.stereo)
|
||||
// Anchor the input route explicitly; without it the route can collapse during
|
||||
// the mode switch (.voiceChat → .default) and the A2DP output dies.
|
||||
try session.setPreferredInput(builtIn)
|
||||
// Commit the data source at the session level. setPreferredDataSource alone only
|
||||
// sets the port-level preference; setInputDataSource makes it the active source.
|
||||
try session.setInputDataSource(stereoSource)
|
||||
logger.info("stereo capsule enabled — source=\(stereoSource.dataSourceName), pattern=.stereo, input anchored")
|
||||
} catch {
|
||||
logger.error("stereo capsule setup failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure mono capture on an explicitly selected port: apply the user's chosen data source
|
||||
/// (orientation) and polar pattern, resetting any prior `.stereo` pattern back to default.
|
||||
private func configureMonoCapture(session: AVAudioSession, port: AVAudioSessionPortDescription) {
|
||||
guard let dataSourceId = selectedDataSourceId, !dataSourceId.isEmpty,
|
||||
let dataSource = port.dataSources?.first(where: {
|
||||
String(describing: $0.dataSourceID) == dataSourceId
|
||||
}) else {
|
||||
// No explicit capsule choice — make sure we're not stuck on a prior .stereo pattern.
|
||||
clearStereoPolarPattern(session: session)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try port.setPreferredDataSource(dataSource)
|
||||
logger.info("setPreferredDataSource ok — \(dataSource.dataSourceName)")
|
||||
} catch {
|
||||
logger.error("setPreferredDataSource failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
if let polarPattern = selectedPolarPattern, !polarPattern.isEmpty {
|
||||
let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern)
|
||||
try? dataSource.setPreferredPolarPattern(pattern)
|
||||
logger.info("setPreferredPolarPattern ok — \(polarPattern)")
|
||||
} else {
|
||||
// Clear any prior .stereo selection so mono capture returns to a mono capsule.
|
||||
try? dataSource.setPreferredPolarPattern(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset any built-in-mic data source that's currently on the `.stereo` polar pattern back to
|
||||
/// the default (mono) pattern. Used when switching from a stereo session back to mono with no
|
||||
/// explicit capsule selection, so the prior stereo capsule doesn't linger.
|
||||
private func clearStereoPolarPattern(session: AVAudioSession) {
|
||||
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
|
||||
else { return }
|
||||
for ds in builtIn.dataSources ?? [] where ds.selectedPolarPattern == .stereo {
|
||||
try? ds.setPreferredPolarPattern(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func modeLabel(_ mode: AVAudioSession.Mode) -> String {
|
||||
switch mode {
|
||||
case .voiceChat: return "voiceChat"
|
||||
case .measurement: return "measurement"
|
||||
case .videoRecording: return "videoRecording"
|
||||
case .default: return "default"
|
||||
default: return "other"
|
||||
}
|
||||
}
|
||||
|
||||
private func optionsLabel(_ opts: AVAudioSession.CategoryOptions) -> String {
|
||||
var parts: [String] = []
|
||||
if opts.contains(.defaultToSpeaker) { parts.append("defaultToSpeaker") }
|
||||
if opts.contains(.mixWithOthers) { parts.append("mixWithOthers") }
|
||||
if opts.contains(.allowBluetoothHFP) { parts.append("allowBluetoothHFP") }
|
||||
if opts.contains(.allowBluetoothA2DP) { parts.append("allowBluetoothA2DP") }
|
||||
return parts.joined(separator: ",")
|
||||
}
|
||||
|
||||
/// Apply stored preferences from UserDefaults. Called at app launch (before any
|
||||
/// audio session activation).
|
||||
func loadStoredPreferences() {
|
||||
if let raw = UserDefaults.standard.string(forKey: kBluetoothMode),
|
||||
let mode = BluetoothMode(rawValue: raw) {
|
||||
bluetoothMode = mode
|
||||
}
|
||||
if let raw = UserDefaults.standard.string(forKey: kMicMode),
|
||||
let mode = MicMode(rawValue: raw) {
|
||||
micMode = mode
|
||||
}
|
||||
if let raw = UserDefaults.standard.string(forKey: kCaptureChannels),
|
||||
let ch = CaptureChannels(rawValue: raw) {
|
||||
captureChannels = ch
|
||||
}
|
||||
selectedInputPortId = UserDefaults.standard.string(forKey: kInputPortId)
|
||||
selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId)
|
||||
selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern)
|
||||
}
|
||||
/// Persist current selections to UserDefaults.
|
||||
func savePreferences() {
|
||||
UserDefaults.standard.set(bluetoothMode.rawValue, forKey: kBluetoothMode)
|
||||
UserDefaults.standard.set(micMode.rawValue, forKey: kMicMode)
|
||||
UserDefaults.standard.set(captureChannels.rawValue, forKey: kCaptureChannels)
|
||||
UserDefaults.standard.set(selectedInputPortId, forKey: kInputPortId)
|
||||
UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId)
|
||||
UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern)
|
||||
}
|
||||
|
||||
// MARK: - Selection setters (called from SettingsView pickers)
|
||||
|
||||
func selectInputPort(_ portId: String) {
|
||||
selectedInputPortId = portId
|
||||
selectedDataSourceId = nil
|
||||
selectedPolarPattern = nil
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
refreshRoutes()
|
||||
}
|
||||
|
||||
func selectDataSource(_ dataSourceId: String) {
|
||||
selectedDataSourceId = dataSourceId
|
||||
selectedPolarPattern = nil
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
refreshRoutes()
|
||||
}
|
||||
|
||||
func selectPolarPattern(_ pattern: String) {
|
||||
selectedPolarPattern = pattern
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
refreshRoutes()
|
||||
}
|
||||
|
||||
func selectBluetoothMode(_ mode: BluetoothMode) {
|
||||
bluetoothMode = mode
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
refreshRoutes()
|
||||
}
|
||||
|
||||
func selectMicMode(_ mode: MicMode) {
|
||||
micMode = mode
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
updateWarnings()
|
||||
}
|
||||
|
||||
func selectCaptureChannels(_ channels: CaptureChannels) {
|
||||
captureChannels = channels
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
// Update the core's stored capture channel count (does not restart the engine).
|
||||
if let streamId = AudioSessionManager.shared.activeMicStreamId {
|
||||
_ = AudioSessionManager.shared.client?.setCaptureChannels(
|
||||
streamId: streamId, channels: channels.channelCount)
|
||||
}
|
||||
// Restart the engine AFTER AVAudioSession routing has settled and the channel
|
||||
// count is stored. The engine reopens playback first (committing the A2DP/output
|
||||
// route), then capture — avoiding the race where stereo capture activation drops
|
||||
// A2DP before the playback device has a chance to claim the route.
|
||||
_ = AudioSessionManager.shared.client?.audioRestart()
|
||||
}
|
||||
|
||||
// MARK: - Presets
|
||||
|
||||
/// Apply a preset — sets all individual audio settings to the preset's values, then
|
||||
/// applies the configuration. For presets that use the built-in mic (A2DP presets),
|
||||
/// finds the built-in mic port UID from availableInputs.
|
||||
func applyPreset(_ preset: AudioPreset) {
|
||||
guard preset != .custom else { return } // can't "apply" custom — it's a display state
|
||||
|
||||
bluetoothMode = preset.bluetoothMode
|
||||
micMode = preset.micMode
|
||||
captureChannels = preset.captureChannels
|
||||
|
||||
if preset.usesBuiltInMic {
|
||||
// Find the built-in mic port from available inputs and select it.
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
if let builtInMic = (session.availableInputs ?? []).first(where: {
|
||||
$0.portType == .builtInMic
|
||||
}) {
|
||||
selectedInputPortId = builtInMic.uid
|
||||
}
|
||||
// Don't set a specific data source — in stereo mode, iOS uses multiple mic
|
||||
// capsules automatically. In mono, the default orientation is fine.
|
||||
selectedDataSourceId = nil
|
||||
selectedPolarPattern = nil
|
||||
} else {
|
||||
// For Default and Bluetooth Headset presets, let the system pick the input.
|
||||
selectedInputPortId = nil
|
||||
selectedDataSourceId = nil
|
||||
selectedPolarPattern = nil
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(preset.rawValue, forKey: kPreset)
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
// Update the core's stored capture channel count (does not restart the engine).
|
||||
if let streamId = AudioSessionManager.shared.activeMicStreamId {
|
||||
_ = AudioSessionManager.shared.client?.setCaptureChannels(
|
||||
streamId: streamId, channels: preset.captureChannels.channelCount)
|
||||
}
|
||||
// Restart the engine AFTER AVAudioSession routing has settled and the channel
|
||||
// count is stored. Playback opens first (commits A2DP route), then capture.
|
||||
_ = AudioSessionManager.shared.client?.audioRestart()
|
||||
refreshRoutes()
|
||||
logger.info("applyPreset — \(preset.rawValue)")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Update warning indicators for the Settings UI.
|
||||
private func updateWarnings() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let outputIsSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
|
||||
// Raw/Studio mode + speaker = echo risk (no AEC in .measurement mode)
|
||||
showsRawModeSpeakerWarning = (micMode == .raw && outputIsSpeaker)
|
||||
// A2DP output runs without hardware AEC (the .voiceChat AEC path isn't available on an
|
||||
// A2DP route). Applies to both mono and stereo A2DP. Stereo also has no AEC (it can't
|
||||
// use .voiceChat at all), but the message is the same and the warning already shows when
|
||||
// the bluetooth mode is A2DP.
|
||||
showsA2dpNoAecWarning = (bluetoothMode == .builtInMicBtA2dp)
|
||||
}
|
||||
|
||||
/// Whether the current configuration wants Bluetooth A2DP output. Used after session
|
||||
/// activation to clear any lingering speaker override that would pin output to the speaker.
|
||||
var wantsA2dpOutput: Bool { bluetoothMode == .builtInMicBtA2dp }
|
||||
|
||||
/// The selected input port object, if any.
|
||||
var selectedPort: IOSAudioInputPort? {
|
||||
inputPorts.first(where: { $0.id == selectedInputPortId })
|
||||
}
|
||||
|
||||
/// The data sources of the selected input port, if it's the built-in mic.
|
||||
var selectedPortDataSources: [IOSAudioDataSource]? {
|
||||
selectedPort?.dataSources
|
||||
}
|
||||
|
||||
/// Whether the selected input port is the built-in mic (has data sources / orientation).
|
||||
var selectedPortIsBuiltInMic: Bool {
|
||||
selectedPort?.portType == AVAudioSession.Port.builtInMic.rawValue
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>VoiceCat</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2026 VoiceCat contributors. All rights reserved.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>VoiceCat needs microphone access to transmit your voice in channels.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user