Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -0,0 +1,24 @@
|
||||
# Git history — large and never needed inside the build context
|
||||
.git/
|
||||
|
||||
# Previous build outputs
|
||||
build/
|
||||
|
||||
# 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
|
||||
.clang-format
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,87 @@
|
||||
name: Build Linux Binaries
|
||||
|
||||
# Builds stripped voicecat-server + voicecat-admin for linux/amd64 and linux/arm64.
|
||||
# Run manually from the Actions tab, or on any push to main.
|
||||
# Artifacts are downloadable from the workflow run for ~90 days.
|
||||
|
||||
on:
|
||||
workflow_dispatch: # manual trigger from the Actions tab
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'core/**'
|
||||
- 'server/**'
|
||||
- 'tools/**'
|
||||
- 'cmake/**'
|
||||
- 'CMakeLists.txt'
|
||||
- 'CMakePresets.json'
|
||||
- 'vcpkg.json'
|
||||
- 'Dockerfile'
|
||||
- '.github/workflows/build-linux.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-24.04
|
||||
vcpkg_triplet: x64-linux
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
vcpkg_triplet: arm64-linux
|
||||
|
||||
name: linux/${{ matrix.arch }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache vcpkg packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/vcpkg
|
||||
/usr/local/share/vcpkg/buildtrees
|
||||
key: vcpkg-${{ matrix.vcpkg_triplet }}-${{ hashFiles('vcpkg.json') }}
|
||||
restore-keys: |
|
||||
vcpkg-${{ matrix.vcpkg_triplet }}-
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential cmake ninja-build git curl zip unzip tar \
|
||||
pkg-config autoconf autoconf-archive automake libtool nasm python3
|
||||
|
||||
- name: Set up vcpkg
|
||||
run: |
|
||||
VCPKG_COMMIT=$(jq -r '."builtin-baseline"' vcpkg.json)
|
||||
git init /tmp/vcpkg
|
||||
git -C /tmp/vcpkg remote add origin https://github.com/microsoft/vcpkg.git
|
||||
git -C /tmp/vcpkg fetch --depth=1 origin "$VCPKG_COMMIT"
|
||||
git -C /tmp/vcpkg checkout FETCH_HEAD
|
||||
/tmp/vcpkg/bootstrap-vcpkg.sh -disableMetrics
|
||||
echo "VCPKG_ROOT=/tmp/vcpkg" >> "$GITHUB_ENV"
|
||||
echo "VCPKG_DISABLE_METRICS=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build (server-release preset)
|
||||
run: |
|
||||
cmake --preset server-release
|
||||
cmake --build --preset server-release
|
||||
|
||||
- name: Collect binaries
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp build/server-release/bin/voicecat-server dist/
|
||||
cp build/server-release/bin/voicecat-admin dist/
|
||||
file dist/voicecat-server dist/voicecat-admin
|
||||
ls -lh dist/
|
||||
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: voicecat-linux-${{ matrix.arch }}
|
||||
path: dist/
|
||||
retention-days: 90
|
||||
+5
-3
@@ -14,9 +14,8 @@
|
||||
*.exe
|
||||
*.pdb
|
||||
|
||||
# vcpkg
|
||||
# vcpkg (bundled as a submodule at /vcpkg — see docs/building.md §2)
|
||||
/vcpkg_installed/
|
||||
/vcpkg/
|
||||
|
||||
# Generated protobuf
|
||||
*.pb.cc
|
||||
@@ -36,7 +35,7 @@
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Apple / Windows client build artifacts (added in M4)
|
||||
# Apple / Windows client build artifacts
|
||||
clients/apple/**/build/
|
||||
clients/apple/**/*.xcodeproj/xcuserdata/
|
||||
clients/apple/**/*.xcodeproj/project.xcworkspace/
|
||||
@@ -51,3 +50,6 @@ clients/apple/Package.resolved
|
||||
# Test artifacts: TOFU pin store written by vc_client during headless tests
|
||||
# (core/src/core/client.cpp falls back to this relative path when tofu_store_path is unset).
|
||||
voicecat_tofu_pins.txt
|
||||
|
||||
# Python bytecode cache (e.g. scripts/asc_api.py)
|
||||
__pycache__/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "vcpkg"]
|
||||
path = vcpkg
|
||||
url = https://github.com/microsoft/vcpkg.git
|
||||
@@ -38,15 +38,20 @@ making progress.
|
||||
|
||||
## Build
|
||||
|
||||
Default development preset (real deps via vcpkg — works on Windows/Linux/macOS):
|
||||
Default development preset (real deps via vcpkg — works on Windows/Linux/macOS). vcpkg is
|
||||
bundled as a git submodule at `vcpkg/`, pinned to `vcpkg.json`'s `builtin-baseline`:
|
||||
|
||||
```bash
|
||||
export VCPKG_ROOT=/path/to/vcpkg # bootstrap vcpkg first; cross-platform
|
||||
git submodule update --init vcpkg # one-time, after cloning
|
||||
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||
cmake --preset dev
|
||||
cmake --build --preset dev
|
||||
ctest --preset dev
|
||||
```
|
||||
|
||||
To use an external vcpkg checkout instead, `export VCPKG_ROOT=/path/to/vcpkg` — it always
|
||||
takes priority over the bundled submodule.
|
||||
|
||||
Skeleton (no third-party deps — works immediately, no vcpkg needed):
|
||||
|
||||
```bash
|
||||
@@ -68,7 +73,8 @@ 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).
|
||||
by [`cmake/voicecat-toolchain.cmake`](cmake/voicecat-toolchain.cmake), which also resolves
|
||||
`VCPKG_ROOT` (env var override, else the bundled `vcpkg/` submodule).
|
||||
|
||||
## Where each subsystem lives (and its doc)
|
||||
|
||||
|
||||
@@ -8,10 +8,14 @@ and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](
|
||||
> 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.
|
||||
> `clients/apple/iOS/`. `ctest --preset dev` green — 29/29 tests.
|
||||
> External PCM feed/tap API (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) shipped.
|
||||
> **Screen-audio sharing shipped on macOS (ScreenCaptureKit) and iOS (ReplayKit Broadcast
|
||||
> Upload Extension → host App Group ring → `vc_stream_feed_pcm`).** See [`PROGRESS.md`](PROGRESS.md).
|
||||
> Upload Extension → host App Group ring → `vc_stream_feed_pcm`).**
|
||||
> **Noise suppression shipped (RNNoise, vendored at `third_party/rnnoise/`)** — both send-side
|
||||
> mic NR (`vc_set_input_noise_reduction`) and per-listener receive NR; client on/off toggles ship
|
||||
on all three clients (receive NR now denoises stereo mic streams too — fixed 2026-06-23).
|
||||
> See [`PROGRESS.md`](PROGRESS.md).
|
||||
|
||||
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control)
|
||||
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives
|
||||
@@ -64,10 +68,18 @@ Vcpkg triplet is auto-resolved from the host platform by
|
||||
Windows, `x64-linux` on Linux, `arm64-osx` on Apple Silicon. See docs/building.md §1
|
||||
"Platform matrix" for details.
|
||||
|
||||
One-time vcpkg setup:
|
||||
vcpkg is bundled as a git submodule at `vcpkg/`, pinned to the commit in `vcpkg.json`'s
|
||||
`builtin-baseline`. One-time setup after cloning:
|
||||
|
||||
```bash
|
||||
git submodule update --init vcpkg
|
||||
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||
```
|
||||
|
||||
To use an external vcpkg checkout instead (e.g. one shared across projects), set
|
||||
`VCPKG_ROOT` — it always takes priority over the bundled submodule:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
|
||||
+17
-10
@@ -3,7 +3,8 @@ cmake_minimum_required(VERSION 3.25)
|
||||
project(voicecat
|
||||
VERSION 0.0.1
|
||||
DESCRIPTION "Self-hosted native voice & text chat (see docs/)"
|
||||
LANGUAGES CXX)
|
||||
# C is needed for the vendored RNNoise noise-suppression lib (third_party/rnnoise).
|
||||
LANGUAGES CXX C)
|
||||
|
||||
# On iOS, audio_engine.cpp includes miniaudio.h which pulls in AVFoundation Objective-C
|
||||
# headers. Those cannot be compiled as C++; we set audio_engine.cpp's LANGUAGE to OBJCXX
|
||||
@@ -13,11 +14,6 @@ if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
|
||||
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)
|
||||
@@ -36,6 +32,20 @@ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
|
||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
|
||||
|
||||
# ── Static MinGW runtime (Windows) ────────────────────────────────────────────
|
||||
# x64-mingw-static only statically links vcpkg's OWN deps (protobuf, sodium, ...);
|
||||
# the GCC/MinGW runtime stays dynamic by default, so every produced .exe/.dll
|
||||
# otherwise depends on libgcc_s_seh-1.dll / libwinpthread-1.dll / libstdc++-6.dll
|
||||
# at runtime — DLLs that exist on a dev box (MSYS2/UCRT64) but not on a clean
|
||||
# Windows machine, where the server then fails to start with "… was not found".
|
||||
# Apply the fully-static-MinGW recipe to ALL targets so binaries are portable.
|
||||
# (The SHARED voicecat.dll already sets these in core/CMakeLists.txt; the duplicate
|
||||
# is harmless. Verify with: objdump -p build/<preset>/bin/voicecat-server.exe |
|
||||
# grep "DLL Name" — only Windows system DLLs should remain.)
|
||||
if(WIN32 AND MINGW)
|
||||
add_link_options(-static-libgcc -static-libstdc++ -static -lwinpthread)
|
||||
endif()
|
||||
|
||||
# ── Targets ───────────────────────────────────────────────────────────────────
|
||||
add_subdirectory(core)
|
||||
|
||||
@@ -45,10 +55,8 @@ 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()
|
||||
@@ -56,5 +64,4 @@ if(VOICECAT_BUILD_TESTS)
|
||||
endif()
|
||||
|
||||
message(STATUS "VoiceCat ${PROJECT_VERSION} configured "
|
||||
"(vcpkg deps: ${VOICECAT_USE_VCPKG_DEPS}, "
|
||||
"server: ${VOICECAT_BUILD_SERVER}, tools: ${VOICECAT_BUILD_TOOLS})")
|
||||
"(server: ${VOICECAT_BUILD_SERVER}, tools: ${VOICECAT_BUILD_TOOLS})")
|
||||
|
||||
+8
-21
@@ -5,28 +5,17 @@
|
||||
{
|
||||
"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.",
|
||||
"description": "Shared base for all presets that link real deps via vcpkg. Uses cmake/voicecat-toolchain.cmake, which auto-resolves VCPKG_TARGET_TRIPLET / VCPKG_HOST_TRIPLET from the host platform (x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon). Cross-compile presets override VCPKG_TARGET_TRIPLET in their cacheVariables. Resolves vcpkg from the bundled git submodule (vcpkg/) unless VCPKG_ROOT points at an external checkout.",
|
||||
"generator": "Ninja",
|
||||
"toolchainFile": "${sourceDir}/cmake/voicecat-toolchain.cmake",
|
||||
"cacheVariables": {
|
||||
"VOICECAT_USE_VCPKG_DEPS": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "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.",
|
||||
"description": "Day-to-day development preset. Real protocol, crypto, voice, server. Builds server + tools + tests. Auto-triplet: x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon. Requires vcpkg bootstrapped (bundled submodule or VCPKG_ROOT).",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/dev",
|
||||
"cacheVariables": {
|
||||
@@ -38,7 +27,7 @@
|
||||
{
|
||||
"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.",
|
||||
"description": "Optimized build with the full test suite enabled. Use to run tests against optimized code, profile, or catch optimizer-sensitive bugs. Symbols are kept (not stripped) so stack traces and profiling remain useful. Auto-triplet. Requires vcpkg bootstrapped (bundled submodule or VCPKG_ROOT).",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/release",
|
||||
"cacheVariables": {
|
||||
@@ -50,7 +39,7 @@
|
||||
{
|
||||
"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.",
|
||||
"description": "Production-shaped build for deployment. Optimized (Release) with stripped binaries (-s linker flag), no tests. This is what you'd ship/run — see docs/deployment.md. Auto-triplet. Requires vcpkg bootstrapped (bundled submodule or VCPKG_ROOT).",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/server-release",
|
||||
"cacheVariables": {
|
||||
@@ -63,7 +52,7 @@
|
||||
},
|
||||
{
|
||||
"name": "windows-client",
|
||||
"displayName": "Windows client (voicecat.dll for C# WinForms, M4)",
|
||||
"displayName": "Windows client (voicecat.dll for C# WinForms)",
|
||||
"description": "Produces a redistributable Release voicecat.dll with no MinGW runtime DLL dependencies (see core/CMakeLists.txt's static-runtime link flags and clients/windows/README.md). Server/tools/tests are off — this preset exists only to build the DLL. Windows only.",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/windows-client",
|
||||
@@ -78,7 +67,7 @@
|
||||
{
|
||||
"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.",
|
||||
"description": "SCAFFOLDING — not yet CI-validated; build on macOS to verify. Produces a static libvoicecat.a for macOS (arm64-osx on Apple Silicon, x64-osx on Intel) for consumption by the Swift Package / XCFramework. Server/tools/tests off. Requires vcpkg bootstrapped (bundled submodule or VCPKG_ROOT).",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/apple-dev",
|
||||
"cacheVariables": {
|
||||
@@ -91,7 +80,7 @@
|
||||
{
|
||||
"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).",
|
||||
"description": "Cross-compiles a static libvoicecat.a for iOS device (arm64). One slice of the XCFramework. Server/tools/tests off. Requires vcpkg bootstrapped (bundled submodule or VCPKG_ROOT) and a macOS host with iOS SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios.cmake (release-only, correct autoconf host triple).",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/apple-ios",
|
||||
"cacheVariables": {
|
||||
@@ -111,7 +100,7 @@
|
||||
{
|
||||
"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).",
|
||||
"description": "Cross-compiles a static libvoicecat.a for iOS simulator (arm64-ios-simulator). One slice of the XCFramework. Server/tools/tests off. Requires vcpkg bootstrapped (bundled submodule or VCPKG_ROOT) and a macOS host with iOS simulator SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake (release-only, correct autoconf host triple).",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/apple-ios-sim",
|
||||
"cacheVariables": {
|
||||
@@ -130,7 +119,6 @@
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
{ "name": "skeleton", "configurePreset": "skeleton" },
|
||||
{ "name": "dev", "configurePreset": "dev" },
|
||||
{ "name": "release", "configurePreset": "release" },
|
||||
{ "name": "server-release", "configurePreset": "server-release" },
|
||||
@@ -140,7 +128,6 @@
|
||||
{ "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 } }
|
||||
]
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 1 — Build
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM ubuntu:24.04 AS builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
cmake \
|
||||
ninja-build \
|
||||
git \
|
||||
curl \
|
||||
zip \
|
||||
unzip \
|
||||
tar \
|
||||
pkg-config \
|
||||
ca-certificates \
|
||||
autoconf \
|
||||
autoconf-archive \
|
||||
automake \
|
||||
libtool \
|
||||
nasm \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Fetch vcpkg at the exact commit pinned in vcpkg.json builtin-baseline.
|
||||
# vcpkg resolves baselines via `git show <sha>:versions/baseline.json`, so it
|
||||
# needs a real .git repo — not a tarball. A single shallow fetch is fast (~30 MB)
|
||||
# and gives vcpkg exactly what it needs.
|
||||
ARG VCPKG_COMMIT=d46283cf33cf5de7bd88e12156ce03882be1f179
|
||||
RUN git init /vcpkg \
|
||||
&& git -C /vcpkg remote add origin https://github.com/microsoft/vcpkg.git \
|
||||
&& git -C /vcpkg fetch --depth=1 origin "${VCPKG_COMMIT}" \
|
||||
&& git -C /vcpkg checkout FETCH_HEAD \
|
||||
&& /vcpkg/bootstrap-vcpkg.sh -disableMetrics
|
||||
ENV VCPKG_ROOT=/vcpkg
|
||||
ENV VCPKG_DISABLE_METRICS=1
|
||||
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
|
||||
ARG TARGETARCH
|
||||
# Three cache mounts:
|
||||
# downloads — source tarballs (~200 MB); safe to share across arches
|
||||
# vcpkg-cache — vcpkg binary cache (pre-built .zip archives per package ABI);
|
||||
# restores packages in seconds on subsequent builds instead of
|
||||
# recompiling. Scoped by arch so amd64/arm64 don't collide.
|
||||
# buildtrees — NOT cached; deleted at end of layer so neither the Docker
|
||||
# image nor the BuildKit cache accumulates several GB of
|
||||
# intermediate build artifacts.
|
||||
ENV VCPKG_BINARY_SOURCES="clear;files,/vcpkg-cache,readwrite"
|
||||
RUN --mount=type=cache,target=/vcpkg/downloads \
|
||||
--mount=type=cache,target=/vcpkg-cache,id=vc-bin-${TARGETARCH} \
|
||||
cmake --preset server-release \
|
||||
&& cmake --build --preset server-release \
|
||||
&& rm -rf /vcpkg/buildtrees
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 2 — Export (binary-only, used by scripts/build-linux-binaries.sh)
|
||||
# docker buildx build --target export --output type=local,dest=./dist/linux-amd64 .
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM scratch AS export
|
||||
COPY --from=builder /src/build/server-release/bin/voicecat-server /voicecat-server
|
||||
COPY --from=builder /src/build/server-release/bin/voicecat-admin /voicecat-admin
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 3 — Runtime (default stage — must be last)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM ubuntu:24.04 AS runtime
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ca-certificates is useful if the server ever makes outbound TLS calls; also
|
||||
# satisfies any mbedTLS system-CA lookup at runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd -r voicecat && useradd -r -g voicecat -s /sbin/nologin voicecat
|
||||
|
||||
COPY --from=builder /src/build/server-release/bin/voicecat-server /usr/local/bin/voicecat-server
|
||||
COPY --from=builder /src/build/server-release/bin/voicecat-admin /usr/local/bin/voicecat-admin
|
||||
|
||||
RUN mkdir -p /data && chown voicecat:voicecat /data
|
||||
|
||||
USER voicecat
|
||||
|
||||
# Persistent state: Ed25519 identity key, self-signed TLS cert, SQLite database.
|
||||
VOLUME ["/data"]
|
||||
|
||||
# Control (TLS 1.3) and media (ChaCha20-Poly1305) share one port number on TCP+UDP.
|
||||
EXPOSE 8384/tcp
|
||||
EXPOSE 8384/udp
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/voicecat-server"]
|
||||
CMD ["--data-dir", "/data"]
|
||||
+670
-2
@@ -10,6 +10,612 @@ up instantly. Newest status at the top.
|
||||
|
||||
## ▶ Where we left off / next action
|
||||
|
||||
- **Done (2026-07-23):** **First comment-density cleanup across core, server, and native
|
||||
clients.** Condensed comments in the highest-noise audio, reconnect, registry, and binding
|
||||
files; removed implementation history and narration; retained ABI ownership, threading,
|
||||
real-time, ordering, and OS-API invariants. Moved the durable iOS audio-routing/pacing rules
|
||||
to `docs/voice.md` and client reconnection policy to `docs/protocol.md`. No behavior, wire
|
||||
format, or C ABI changes. **Verification:** `cmake --build --preset dev` green;
|
||||
`ctest --preset dev` 29/29 green. `dotnet build VoiceCat.slnx` restores dependencies and
|
||||
builds `VoiceCat.Interop` + `VoiceCat.App`, then fails in the unchanged test project because
|
||||
`ExternalPcmTests.cs:49` references internal `NativeMethods` (`CS0122`).
|
||||
|
||||
- **Done (2026-06-25):** **Fixed iOS AirPods-disconnect reinitialize loop on A2DP presets
|
||||
(Stereo Mic / Mono Mic).** Regression from the 2026-06-25 audio-device-change recovery
|
||||
commit below, which broadened the route-change recovery set from
|
||||
`{oldDeviceUnavailable, newDeviceAvailable}` to "everything except
|
||||
categoryChange/routeConfigurationChange". That added `.override` to the recovery set, and
|
||||
`.override` is fired by our own `applyA2dpSpeakerFallback()` →
|
||||
`overrideOutputAudioPort(.speaker)` — which `recoverAudio()` calls on every recovery. On an
|
||||
A2DP preset with AirPods connected, disconnecting them ran:
|
||||
`oldDeviceUnavailable` → `recoverAudio()` → `applyA2dpSpeakerFallback()` (no external
|
||||
output now) → `overrideOutputAudioPort(.speaker)` → `.override` routeChange →
|
||||
`recoverAudio()` → `applyConfiguration()` (setCategory resets the override) →
|
||||
`applyA2dpSpeakerFallback()` → `overrideOutputAudioPort(.speaker)` → `.override` → …
|
||||
Each iteration also called `IOSAudioEngine.reconfigure()` → `rebuild()` (a full
|
||||
stop/restart of `AVAudioEngine`), which is the audible reinitialize loop + CPU spin the
|
||||
user reported. Voice Chat (`.btHfpVoice`) and Built-in Mic + Speaker were unaffected
|
||||
because `applyA2dpSpeakerFallback` early-returns for non-A2DP modes (no
|
||||
`overrideOutputAudioPort` call, no `.override` notification).
|
||||
|
||||
Two-part fix (no C ABI / proto / docs changes — pure Swift iOS-app target):
|
||||
1. **`AudioSessionManager.handleRouteChange`** (`AudioSessionManager.swift:182`): added
|
||||
`.override` to the skip list alongside `.categoryChange`/`.routeConfigurationChange`.
|
||||
`.override` is only ever fired by our own `overrideOutputAudioPort` call, so treating
|
||||
it as a recovery reason is the loop by definition. The
|
||||
`AVAudioEngineConfigurationChange` observer in `IOSVoiceProcessingEngine` remains as
|
||||
the backstop for the case where an override actually stops the engine.
|
||||
2. **`IOSAudioRouter.applyA2dpSpeakerFallback`** (`IOSAudioRouter.swift`): made idempotent
|
||||
via a `lastAppliedOutputOverride` tracker. Skips the `overrideOutputAudioPort` call
|
||||
when the desired override (`.none` for external output present, `.speaker` otherwise)
|
||||
already matches the last successfully applied value — so even if some other path
|
||||
re-enters, the redundant override (and its `.override` notification) isn't fired. The
|
||||
tracker is reset to `nil` at the top of `applyConfiguration()` (setCategory can reset
|
||||
the override) and on a failed call. Defense-in-depth on top of fix 1.
|
||||
|
||||
**Build:** `xcodebuild -project clients/apple/iOS/VoiceCatiOS.xcodeproj -scheme VoiceCatiOS
|
||||
-destination 'generic/platform=iOS' build` green (Xcode 26.5 / iOS 18.0). The standalone
|
||||
`swift test` in `clients/apple/` fails with `no such module 'VoiceCatC'` — pre-existing
|
||||
(confirmed by stashing the changes: fails identically without them); the `VoiceCatC` C ABI
|
||||
XCFramework isn't on SwiftPM's resolver path in this workspace. Not caused by this change
|
||||
(the edit is in the iOS app target, not the `VoiceCatCore` SwiftPM package).
|
||||
**Next (manual, on-device):** connect on the Stereo Mic preset, join voice, disconnect
|
||||
AirPods — expect ONE `oldDeviceUnavailable` → one `recoverAudio` → one `engine started` →
|
||||
one `override` routeChange (skipped, no further `recoverAudio`) and steady audio through
|
||||
the loudspeaker. Also sanity-check AirPods reconnect and wired headphone plug/unplug
|
||||
recover exactly once.
|
||||
|
||||
- **Done (2026-06-25):** **iOS robustness — auto-reconnect after a network change + audio
|
||||
recovery when audio devices plug/unplug.** Two layers of bugs the iOS client had:
|
||||
(a) a `VC_EVENT_DISCONNECTED` from the C core on a Wi-Fi→cellular flip / DNS outage /
|
||||
server restart used to leave the session dead with no retry; (b) unplugging wired
|
||||
headphones or AirPods left the engine stopped forever — mic stopped transmitting and
|
||||
remote audio stayed silent (the server connection itself survived, but the audio graph
|
||||
did not recover).
|
||||
|
||||
The first attempt wired reconnect into `AppState.handleConnectEvent`, but that handler
|
||||
never runs for a live-session disconnect: once `SessionState.init` overwrites
|
||||
`client.onEvent` (`SessionState.swift:87`), the `.disconnected` event is delivered to
|
||||
`SessionState.handleEvent`, which used to play a cue and do nothing else. So the live
|
||||
session would sit as a zombie for ~30-60 s (the C core's TCP keepalive/reaper timeout)
|
||||
and then play the "connection lost" sound with no reconnect armed — exactly what the
|
||||
user saw. The fix below has two parts addressing both the missing reconnect AND the
|
||||
long wait.
|
||||
|
||||
1. **Event-driven reconnect** (`AppState.swift`, `SessionState.swift`): added a
|
||||
`weak var appState: AppState?` to `SessionState`, set by AppState on auth success.
|
||||
`SessionState.handleEvent` `.disconnected` now plays the cue and calls
|
||||
`appState?.onLiveSessionDisconnected()` — the SINGLE path by which AppState learns a
|
||||
live session dropped (since its own `handleConnectEvent` is bypassed for live-session
|
||||
events). `onLiveSessionDisconnected` calls a shared `teardownLiveSessionAndReconnect`
|
||||
that snapshots the live session into `LastSession`, stops the audio engine,
|
||||
deactivates the AVAudioSession, nil's `session` (which releases `VoiceCatClient` →
|
||||
`vc_client_destroy` joins the io thread), resets the backoff counter, and arms
|
||||
`scheduleReconnect`.
|
||||
2. **Path-driven proactive reconnect** (`AppState.swift`): an `NWPathMonitor`
|
||||
(`Network.framework`) now runs the whole time we're CONNECTED (started on auth
|
||||
success, not only when armed for reconnect) and stays armed across reconnects. Its
|
||||
`pathUpdateHandler` (dispatched to @MainActor) does two things:
|
||||
- While connected: a primary-interface change (Wi-Fi↔cellular) OR the path becoming
|
||||
`.unsatisfied` triggers `proactiveReconnect()` — tearing the live session down
|
||||
BEFORE the C core notices the dead TCP read. This is what collapses the 30-60 s
|
||||
reaper wait into ~1 s + the first backoff tick. Same-interface refreshes (Wi-Fi
|
||||
BSSID roams, signal-strength changes) are intentionally ignored (signature
|
||||
comparison via `pathSignature`); those usually don't break the TCP connection.
|
||||
- While mid-reconnect (no session): a path becoming `.satisfied` resets the backoff
|
||||
counter and arms `scheduleReconnect` for a fast-fresh retry.
|
||||
`userInitiatedDisconnect` distinguishes manual `disconnect()`/`cancelConnect()` (which
|
||||
set it true → cancel all reconnect state) from a network drop (which leaves it false).
|
||||
On a successful reconnect, `reconnectAttempt` resets and `lastSession` clears; the
|
||||
path monitor keeps watching for the next change. On user-initiated disconnect, all
|
||||
reconnect state (task + path monitor + `lastSession` + `connectedServer`) is cancelled.
|
||||
3. **Backoff + restore**: exponential backoff 1s → 2s → 4s → 8s → 16s → 30s cap,
|
||||
indefinite. TOFU pins match on the second connect (`VC_TOFU_MATCHED`) so the identity
|
||||
gate auto-confirms; on auth success `SessionState.requestRestore` issues a
|
||||
`joinChannel` and re-arms voice + restores the local mute/deafen state on the
|
||||
resulting `.joinResult`.
|
||||
4. **Audio recovery** (`AudioSessionManager.swift`, `IOSVoiceProcessingEngine.swift`):
|
||||
replaced the route-change handler's narrow `.oldDeviceUnavailable`/
|
||||
`.newDeviceAvailable` guard with a single intent-gated `recoverAudio()` path that
|
||||
re-activates the AVAudioSession, re-applies the route config, and rebuilds the
|
||||
engine; it runs on every externally-initiated route change reason except
|
||||
`.categoryChange`/`.routeConfigurationChange` (those we cause ourselves and would
|
||||
loop). Interruption-end now always calls `recoverAudio()` instead of only when
|
||||
`.shouldResume` is set (which left the session permanently dead after Siri). Added
|
||||
an `AVAudioEngineConfigurationChange` observer on the engine in `IOSAudioEngine`
|
||||
that catches the case where iOS stops the engine itself AFTER our route-change
|
||||
handler already rebuilt it (the previous rebuilds raced the engine's own self-stop
|
||||
and lost). And `IOSAudioEngine.rebuild()` now does a one-shot reactivation-retry on
|
||||
`engine.start()` failure — iOS sometimes refuses to start until the AVAudioSession is
|
||||
re-activated, which is the silent-death case.
|
||||
**Build:** `scripts/build-ios-client.sh --no-configure` green (Xcode 26.5 / iOS 18.0 sim
|
||||
SDK, Swift 5 mode). No C ABI / `voicecat.h` / `voicecat.proto` / C core changes; the
|
||||
existing TOFU auto-confirm (`VC_TOFU_MATCHED`) and idempotent `vc_join_channel` make
|
||||
reconnect+restore possible without new C ABI. macOS and Windows clients unchanged.
|
||||
**Next (manual, on-device):** verify unplugging AirPods/wired headphones mid-call keeps
|
||||
audio going through the loudspeaker; verify Wi-Fi→cellular flip mid-call now triggers a
|
||||
FAST reconnect (within a couple seconds, not 30-60 s) and lands in the same channel with
|
||||
voice re-armed; verify tapping Disconnect mid-reconnect-abort cancels cleanly.
|
||||
|
||||
- **Done (2026-06-24):** **Three bug fixes — voice join/leave, channel edit defaults, channel-update stream restart.**
|
||||
1. **Join/Leave Voice now truly subscribes/unsubscribes from the voice plane.** Previously
|
||||
"Join Voice" only started the local mic — receiving was always on (gated by channel
|
||||
membership alone). Added a protocol-level voice subscription concept: new
|
||||
`SubscribeVoiceRequest`/`UnsubscribeVoiceRequest`/`VoiceSubscriptionResult` proto messages
|
||||
(`core/proto/voicecat.proto`), `User.voice_subscribed` field, `vc_join_voice`/`vc_leave_voice`
|
||||
C ABI functions (`core/include/voicecat.h`), `VC_EVENT_VOICE_STATE` event, server-side
|
||||
`voice_subscribed_` flag on `ConnSession` checked by the SFU relay's recipient filter
|
||||
(`SessionRegistry::find_channel_sessions` excludes non-subscribers; `MediaRelay::on_udp_frame`
|
||||
also skips non-subscribed senders). The core client gates `sync_remote_streams` on
|
||||
`voice_subscribed_`, tears down all remote decoders + stops local streams on leave, and
|
||||
re-syncs from the session model on join. All three clients (Windows/macOS/iOS) rewired
|
||||
their Join/Leave Voice button to call `joinVoice`+start mic / `leaveVoice`+core stops mic.
|
||||
The configured input mode (PTT/VAD/AlwaysOn) takes effect on join — no extra mic button.
|
||||
Text chat works regardless of voice subscription. **Apple clients not yet compile-verified
|
||||
(Windows environment).**
|
||||
2. **Channel edit dialog now shows the channel's actual current settings.** The read struct
|
||||
`vc_channel` (`voicecat.h`) was missing `sort_order` and `audio` fields — only the write
|
||||
struct `vc_channel_info` had them. Extended `vc_channel` with both (additive, no ABI break),
|
||||
updated the session model (`session::Channel`) and `apply_snapshot`/`apply_channel_event`
|
||||
to populate them, and updated `vc_list_channels` marshaling. All three clients now build
|
||||
the edit descriptor from the actual channel info instead of hardcoded defaults.
|
||||
3. **Channel parameter updates now automatically restart everyone's streams.** Previously
|
||||
editing a channel's audio config (codec/bitrate/sample-rate/FEC/DTX/etc.) persisted and
|
||||
broadcast a `ChannelEvent::UPDATED`, but no layer restarted streams — encoders/decoders
|
||||
are frozen at announce time. `handle_channel_event` (`core/src/core/client.cpp`) now
|
||||
detects audio-config changes on the user's current channel and calls
|
||||
`restart_active_streams_for_channel`, which stop→starts each active local stream. The
|
||||
server reads the updated channel config on re-announce, and peers' `sync_remote_streams`
|
||||
wire up fresh decoders at the new ssrc. The `LocalStream` struct now retains the stream
|
||||
label across restarts. No server or protocol change needed.
|
||||
|
||||
- **[ ] Soon — jitter buffer should measure REAL arrival jitter (RFC 3550), not sender
|
||||
timestamps.** `JitterBuffer::push` (`core/src/audio/audio_engine.cpp:84-108`) estimates
|
||||
jitter from `gap = ts - last_push_ts_`, where `ts` is the **sender's timestamp** — which is
|
||||
perfectly regular (`ls.timestamp += samples` every frame, independent of when the packet is
|
||||
actually sent). So `diff` is always ~0, `jitter_est_` stays 0, and `target_depth_ms_` is
|
||||
pinned at its ~20 ms floor. The buffer is therefore **blind to real network/arrival jitter
|
||||
and to bursty senders** — it never deepens. Combined with the playout deliberately seeding
|
||||
to near-zero depth (`on_playback`, ~line 715), the receiver tolerates only a *steady*
|
||||
sender. This is exactly why the iOS mic needed a send-side pacing cushion (below) and why
|
||||
genuine network jitter would also cause underruns. **Fix:** measure inter-arrival jitter
|
||||
the RFC 3550 way — `D = (arrival_j - arrival_i) - (ts_j - ts_i)` using a wall-clock arrival
|
||||
stamp captured in `push()` — and drive `target_depth_ms_` off that EWMA (keep the existing
|
||||
marker/silence-gap outlier rejection). Then the receiver absorbs bursts itself and the iOS
|
||||
send cushion could be reduced or removed. Shared-core change → add a test and re-verify
|
||||
desktop↔desktop stays low-latency (steady sender ⇒ ~0 arrival jitter ⇒ no regression).
|
||||
|
||||
- **Done (2026-06-24):** **Windows PTT can now work system-wide (in the background).** Previously
|
||||
the PTT key was focus-scoped (WinForms `KeyDown`/`KeyUp`, dead the moment the window lost
|
||||
focus). Added an AV-safe global path using the **Raw Input API** (`RegisterRawInputDevices` +
|
||||
`WM_INPUT` with `RIDEV_INPUTSINK`) — *not* a `WH_KEYBOARD_LL` low-level hook, which is the
|
||||
keylogger pattern AV heuristics flag (worse for our unsigned MinGW binary). New
|
||||
`clients/windows/VoiceCat.App/Native/RawInput.cs` (P/Invoke + structs); `MainForm` overrides
|
||||
`OnHandleCreated`/`OnHandleDestroyed`/`WndProc` to register the keyboard sink and handle
|
||||
`WM_INPUT`, gates the focus-scoped `KeyDown`/`KeyUp` handlers off when system-wide is on, makes
|
||||
the `Deactivate` force-release conditional, and adds a `GetAsyncKeyState` watchdog on the pump
|
||||
timer so a missed key-up (RDP/lock-screen focus switch) can't leave PTT stuck. New
|
||||
`VoiceSettings.SystemWidePtt` (default ON) with a "Works in the background (system-wide)"
|
||||
checkbox in the Audio settings PTT section. Build green (`dotnet build`, 0 warnings). **Next
|
||||
(manual):** verify background PTT against a live server, and confirm the binary trips no AV
|
||||
keyboard-hook detection.
|
||||
|
||||
- **Done (2026-06-23):** **Fixed: receive-side noise reduction silently skipped on stereo mic
|
||||
streams (regression from stereo-mic capture below).** The per-listener NR toggle
|
||||
(`vc_set_remote_stream(... noise_reduction)`) did nothing on Windows/macOS/iOS — the UI and
|
||||
the whole C-ABI→core path were correctly wired, but the decode loop gated the RNNoise pass on
|
||||
`dec_channels == 1` (`core/src/audio/audio_engine.cpp`), an old proxy for "this stream is
|
||||
voice" that assumed *stereo ⇒ screen-share*. The stereo-mic commit broke it: a stereo mic with
|
||||
**send-side NR off** transmits stereo Opus, so the receiver decoded `dec_channels == 2` and
|
||||
skipped NR entirely (gain/mute have no channel guard, which is why only NR looked broken).
|
||||
**Fix:** thread the stream *kind* through `init_recv_stream` into `RemoteStream::is_voice`
|
||||
(set from `si.kind() == STREAM_MIC` in `client.cpp`), gate receive NR on `is_voice` instead of
|
||||
channel count, and fold a stereo voice frame to mono → denoise → duplicate back across both
|
||||
channels in place (symmetric with the send-side downmix; RNNoise is mono-only). A stereo voice
|
||||
stream now plays mono while NR is on; a screen-audio share is never touched. New test
|
||||
`tests/test_recv_noise_reduction.cpp` drives `AudioEngine` and asserts a stereo voice stream's
|
||||
noise floor collapses with NR on (RMS 1046 → 0.1) while a screen-audio share stays unchanged
|
||||
(RMS ≈ 1015). Full `ctest --preset dev` green — **29/29**. Docs: voice.md §10. Clients need no
|
||||
change (shared-core fix). Not yet re-verified two-client E2E on real hardware.
|
||||
|
||||
- **Done (2026-06-23):** **Stereo mic capture on Windows & macOS desktop clients.** Both
|
||||
desktop mics were hard-mono: `ensure_audio_running()` defaults `capture_channels = 1` and
|
||||
neither client ever called `vc_set_capture_channels` (only iOS did). Added a **"Stereo
|
||||
microphone" toggle** to each client's Audio settings (off by default, persisted —
|
||||
`VoiceSettings.StereoMic` on Windows, `MainWindowController.stereoMic` /
|
||||
`voice.stereoMic` UserDefaults on macOS). It's applied to the core when the mic stream
|
||||
starts (stored on the stream before the announce round-trip, so the first device open picks
|
||||
it up) and live in settings via `vc_set_capture_channels` + `vc_audio_restart`. Exposed both
|
||||
ABI calls in the Windows interop (`NativeMethods`/`VoiceCatClient`); the macOS wrapper already
|
||||
had them. **Core fix:** `encode_and_send_frame` (`core/src/core/client.cpp`) now folds a
|
||||
stereo mic frame to mono when the channel is mono — previously the `channels == 2` branch
|
||||
encoded interleaved L/R directly even on a mono channel, feeding a mono `opus_encode` 2× its
|
||||
samples (wrong pitch / garbage). Real stereo still only reaches the wire on a **stereo
|
||||
channel** (encoder channel count = channel's Opus mode); on a mono channel the mic is cleanly
|
||||
downmixed. Test: `test_stereo_mic_mono_channel` in `tests/test_vad_ptt_devices.cpp`. Full
|
||||
`ctest --preset dev` green — 28/28. macOS Xcode build not compiled here (Windows host); the
|
||||
Swift changes follow existing `nrChanged`/`setInputDevice` patterns. Docs: voice.md §8.
|
||||
|
||||
- **Done (2026-06-23):** **Fixed iOS dual-stream / crackly mic — core opened a second
|
||||
(miniaudio) capture device alongside the AVAudioEngine tap.** Symptom: with two clients in
|
||||
a channel, the remote end heard the iOS mic **twice** and crackly. With Voice Chat + a BT
|
||||
headset, both the BT mic and the internal mic were captured; with Stereo Mic, both a mono
|
||||
and a stereo copy of the internal mic were sent simultaneously. Root cause is a timing gap
|
||||
in `vc_client::ensure_audio_running()` (`core/src/core/client.cpp`): `external_capture` was
|
||||
only set when a MIC stream already existed, but `ensure_audio_running` is also called from
|
||||
`sync_remote_streams` (triggered by the post-auth `ServerStateSnapshot`) **before** the user
|
||||
joins voice — so with no MIC stream, `external_capture` stayed `false` and
|
||||
`AudioEngine::start()` opened a real miniaudio capture device. Later the user joined voice →
|
||||
`IOSAudioEngine.startMic` installed the AVAudioEngine input tap → `feedPcm` →
|
||||
`inject_capture` → `on_capture_frame`. The miniaudio device was still open (the engine was
|
||||
already `running()`, so the later `ensure_audio_running` early-returned and never applied
|
||||
`external_feed`), and `on_capture_frame` encodes+sends every frame with **no deduplication**
|
||||
→ the mic was sent twice. The two unsynchronized capture clocks interleaving in the encoder
|
||||
is the crackle; the mono miniaudio device + stereo AVAudioEngine tap is the "mono and stereo
|
||||
at the same time" on Stereo Mic.
|
||||
- **Fix 1 (core, `core/src/core/client.cpp:ensure_audio_running`):** force
|
||||
`p.external_capture = true` whenever `external_playback_` is set. In iOS unified mode the
|
||||
core must never open a hardware capture device — the AVAudioEngine owns the only mic path.
|
||||
No-op on desktop (`external_playback_` is never set there).
|
||||
- **Fix 2 (iOS, `clients/apple/iOS/VoiceCatiOS/AppState.swift`):** move
|
||||
`client.setExternalPlayback(true)` from the `authResult` handler to **before**
|
||||
`client.connect(...)`. The server sends `AuthResult` immediately followed by
|
||||
`ServerStateSnapshot`; `handle_server_state` runs `ensure_audio_running` on the io thread
|
||||
before the main thread drains `authResult`, so setting the flag post-auth raced. Setting it
|
||||
pre-connect guarantees `external_playback_` is true before any message is processed —
|
||||
eliminating the playback-device race too (the mixer timer + AVAudioEngine playback path +
|
||||
VPIO AEC reference are correct from the first frame).
|
||||
- **Verify:** `cmake --build --preset dev` clean; `ctest --preset dev` = 24/28 — the 4
|
||||
failures (`vad_ptt_devices`, `external_pcm`, `frame_ms_reframe`, `channel_samplerate`) are
|
||||
a **pre-existing** teardown `mutex lock failed` race, reproduced identically with the
|
||||
changes stashed. `external_playback` (the one test exercising this code path) **passes**.
|
||||
No xcframework rebuild needed (no new symbols). **Next (manual, on device):** two clients
|
||||
in a channel — Voice Chat + BT, and Stereo Mic — confirm the remote end hears the iOS mic
|
||||
once, clean (no duplicate, no crackle); confirm the iOS user hears the remote user cleanly
|
||||
with AEC working in Voice Chat.
|
||||
|
||||
- **Done (2026-06-23):** **Fixed iOS mic flutter / crackle / octave-up.** The iOS mic was
|
||||
unusable: a consistent ~40–60 ms flutter with volume fade ("talking through a slow fan") on
|
||||
every preset. Root cause: the core sends each captured frame **synchronously**
|
||||
(`on_capture_frame` → `encode_and_send_frame`, no send pacer), so packet cadence == capture
|
||||
cadence; and the receiver's playout keeps **near-zero buffering** by design and its jitter
|
||||
estimate is blind to arrival timing (see RFC-3550 item above). That's smooth only for a
|
||||
*steady* sender (desktop miniaudio = steady 20 ms), but the iOS `AVAudioEngine` input tap
|
||||
delivers ~2 frames per ~40 ms callback (more under VPIO) → bursty → receiver underruns → PLC
|
||||
fade.
|
||||
- **Fix (iOS-only, `clients/apple/iOS/VoiceCatiOS/IOSVoiceProcessingEngine.swift`):** the
|
||||
mic tap converts to 48 kHz int16 and writes a lock-free SPSC ring; a 20 ms feed pump
|
||||
drains it and calls `feedPcm` at a **steady** cadence so packets leave the core every
|
||||
20 ms (what the receiver expects). The pump **primes a small prebuffer cushion**
|
||||
(`PumpState.targetFrames`, 3 frames ≈ 60 ms, self-healing up to ~120 ms on underrun)
|
||||
before releasing, so the tap's bursts can't drain it to empty. Two correctness rules
|
||||
(each had bit us): never read a partial frame (`read` consumes what it returns →
|
||||
discarding partials caused crackle), and rebuild the pump with the current channel count
|
||||
every `rebuild()` (a frozen channel count fed mono-as-stereo = octave-up on a Stereo→Voice
|
||||
Chat switch). Trade-off: ~60–120 ms added mic-send latency — unavoidable when de-bursting
|
||||
for a near-zero-buffer receiver; the RFC-3550 fix above would let us shrink it.
|
||||
- **Verify:** `xcodebuild` Debug **BUILD SUCCEEDED** (iOS Simulator, arm64). Audible test
|
||||
requires a real device (simulator has no real mic route): mic should be smooth on Voice
|
||||
Chat / Mono Mic / Stereo Mic, including switching presets while live (no octave).
|
||||
|
||||
- **Done (2026-06-23):** **Fixed Apple client link failure (stale xcframework missing
|
||||
RNNoise).** Both `VoiceCatMac` and `VoiceCatiOS` failed to link with `Undefined symbols for
|
||||
architecture arm64: _rnnoise_create / _rnnoise_destroy / _rnnoise_process_frame`. Root
|
||||
cause: `clients/apple/scripts/build-xcframework.sh` merged vcpkg deps into the fat static
|
||||
lib but NOT the locally-built vendored `librnnoise.a` (a CMake target from
|
||||
`third_party/rnnoise/`, linked privately into `voicecat` via `VOICECAT_HAS_NS` — not a
|
||||
vcpkg dep). The xcframework had been rebuilt at 14:17 after the RNNoise commit but still
|
||||
omitted the symbols, so every slice's `libvoicecat-fat.a` referenced `_rnnoise_*` with no
|
||||
defining object. The iOS slices were also stale (pre-rnnoise) and absent from the
|
||||
xcframework entirely.
|
||||
- **Fix:** `build-xcframework.sh` now collects `.a` files from `build/<preset>/lib/`
|
||||
(excluding `libvoicecat*`) in addition to `vcpkg_installed/<triplet>/lib/`, so vendored
|
||||
CMake-target static libs like `librnnoise.a` are merged into the fat lib. Future-proof:
|
||||
any new vendored static-lib target landing in `build/<preset>/lib/` is picked up
|
||||
automatically. README "Fat static library" section updated.
|
||||
- **Verify:** rebuilt `VoiceCatCore.xcframework --all` → all 3 slices (macos-arm64,
|
||||
ios-arm64, ios-arm64-simulator) now carry 10 `_rnnoise_*` symbols each; fat lib
|
||||
~30 MB → ~33 MB. `xcodebuild` Debug **BUILD SUCCEEDED** for `VoiceCatMac`,
|
||||
`VoiceCatiOS` (iphonesimulator arm64), and `VoiceCatiOS` (iphoneos arm64,
|
||||
`CODE_SIGNING_ALLOWED=NO`). No core/ABI/proto changes — xcframework artifact only.
|
||||
|
||||
- **Done (2026-06-23):** **Remote-stream noise suppression — real backend (RNNoise).** The
|
||||
two-sided NR plumbing (`RemoteStream::recv_ns` + `vc_set_remote_stream(... noise_reduction)`)
|
||||
was wired but **inert** — `ApmProcessor::create()` returned a no-op passthrough, because the
|
||||
originally-planned `webrtc-audio-processing` won't build on Windows/macOS. Replaced with
|
||||
**RNNoise** (BSD-3 + CC0), vendored at `third_party/rnnoise/` (the vcpkg port is `!windows
|
||||
!arm`), built as a standalone C static lib + `VOICECAT_HAS_NS`. One `RnnoiseProcessor`
|
||||
(`core/src/audio/apm_processor.cpp`) now backs **both** NR paths:
|
||||
- **Receive-side** (per-listener, per-`ssrc`): lit up automatically via the factory; gated to
|
||||
mono streams (`audio_engine.cpp` ~L791).
|
||||
- **Send-side** (mic, new): `vc_set_input_noise_reduction(client, enable)` ABI +
|
||||
`vc_client::mic_ns_`, run before input gain/VAD in `on_capture_frame`. A stereo mic is
|
||||
downmixed to mono **only when NR is on**; with NR off a stereo mic keeps full stereo.
|
||||
- RNNoise is mono/48 kHz/480-sample; our clock is fixed 48 kHz and Opus frame sizes are all
|
||||
multiples of 480, so no resampling. RT-safe: alloc at construction, lock-free in the callback.
|
||||
- **Verify status:** `ctest --preset dev` green — **28/28** (new `noise_suppression` test:
|
||||
feeds white noise through `ApmProcessor::create()`, measures **99.9%** RMS reduction). Build
|
||||
clean on the `dev` MinGW preset. **Next (manual):** add the on/off toggles to the client UIs
|
||||
(Windows Audio Settings dialog, macOS/iOS settings) calling the two ABIs; build `windows-client`
|
||||
+ `apple-dev` presets to confirm RNNoise compiles under MinGW-DLL and arm64; two-client E2E.
|
||||
|
||||
- **Done (2026-06-23):** **Aux outgoing stream (mic + a second input device) — Windows + macOS.**
|
||||
Users can now transmit a second hardware input device (e.g. line-in / aux) alongside the mic, with
|
||||
its own device picker and volume, from Audio Settings. **No core/ABI/proto changes** — the aux is
|
||||
a `VC_STREAM_AUX_DEVICE` stream started with `external_feed=1`, captured client-side, and fed via
|
||||
`vc_stream_feed_pcm` (the same external-feed pipeline screen-audio uses). Per-kind `local_streams_`
|
||||
already allows mic + screen + one aux to coexist; volume is a client-side gain multiply (the core's
|
||||
`vc_set_input_gain` is mic-only/global). The aux is always-on (the core never gates `AUX_DEVICE` on
|
||||
VAD/PTT) and is tied to the voice session (started on Join Voice when enabled, stopped on Leave).
|
||||
- **Windows:** new `Audio/InputDeviceCapture.cs` (WASAPI shared-mode capture from a real input
|
||||
endpoint via `IMMDevice.Activate(IAudioClient)`, 48 kHz/s16, 20 ms frames) + `InputDeviceEnumerator`
|
||||
(WASAPI capture-endpoint list — separate from the core's miniaudio ids). Aux section in
|
||||
`AudioSettingsForm.cs` (enable checkbox, device combo, refresh, volume slider, accessible names,
|
||||
live-apply + Cancel revert via callbacks). Lifecycle in `MainForm.cs` (`_auxStreamId` +
|
||||
`InputDeviceCapture`). Persisted in `VoiceSettings.cs` (`AuxEnabled/AuxDeviceId/AuxGain`).
|
||||
- **macOS:** new `Audio/InputDeviceCapture.swift` (AVAudioEngine input-node tap pinned to the chosen
|
||||
Core Audio device via `kAudioOutputUnitProperty_CurrentDevice`; AVAudioConverter → 48 kHz int16;
|
||||
20 ms framing modelled on `ScreenAudioCapture`) + `InputDeviceEnumerator` (Core Audio device list
|
||||
by stable UID). Aux section in `SettingsWindowController.swift`; lifecycle + UserDefaults
|
||||
persistence (`voice.aux*`) in `MainWindowController.swift`. New file added to `project.pbxproj`.
|
||||
- **Verify status:** Windows C# solution builds clean (0 warn/0 err); `ctest` core suite unchanged
|
||||
(no core edits). **Next (manual):** on a Mac, build `VoiceCatMac.xcodeproj`; then two-client E2E —
|
||||
enable aux on a second input device, confirm two distinct streams for the sender and that the aux
|
||||
volume slider moves the aux level independently of the mic; confirm persistence across relaunch.
|
||||
|
||||
- **Done (2026-06-23):** **Input-settings persistence, mic input gain, + two iOS bugs (all 3
|
||||
clients).** Four fixes:
|
||||
1. **Input settings now persist.** Transmission mode (VAD/PTT/Always-On), VAD threshold, and the
|
||||
new mic gain were applied to the core + UI but never saved, so every relaunch reset to VAD
|
||||
defaults. Each client now persists them and re-applies on connect: iOS via `UserDefaults`
|
||||
(`SessionState.loadAndApplyVoiceSettings` + setter writes, keys `voice.*`); macOS via
|
||||
`UserDefaults` (`MainWindowController` `didSet` + `loadPersistedAudioSettings`, also restores
|
||||
the VAD slider from the stored threshold); Windows via new
|
||||
`VoiceCat.App/Models/VoiceSettings.cs` (JSON at `%AppData%\VoiceCat\voice.json`, mirrors
|
||||
`FeedbackSettings`) loaded/applied in `MainForm`.
|
||||
2. **Microphone input gain.** New global send-side API `vc_set_input_gain` (voicecat.h →
|
||||
`client.cpp::on_capture_frame`, applied to MIC PCM before the VAD gate, clamped to int16) plus
|
||||
Swift (`setInputGain`) and C# (`SetInputGain`) bindings. Mic-volume slider (0–300 %, default
|
||||
100 %) added to all three clients' input settings, persisted with the rest.
|
||||
3. **iOS chat send fixed.** `ChatView` called `sendText(scope:.channel)` with no `targetId` (→ 0),
|
||||
so channel messages went nowhere; now passes `session.currentChannelId`.
|
||||
4. **iOS per-user tuning reachable via VoiceOver.** The tuning sheet was long-press
|
||||
`.contextMenu` only (invisible to VoiceOver); `UserRow` now also exposes the same buttons as
|
||||
`.accessibilityActions` (no visual change), so the actions rotor reaches tuning + admin actions.
|
||||
- **Verified:** core `cmake --build --preset dev` clean; `ctest --preset dev` = 24/27 (the 3
|
||||
failures — `external_pcm`, `frame_ms_reframe`, `channel_samplerate` — are a pre-existing
|
||||
teardown crash on this machine, reproduced identically with the changes stashed). xcframework
|
||||
rebuilt (`--all`); **VoiceCatMac** and **VoiceCatiOS** (arm64 sim) → BUILD SUCCEEDED;
|
||||
`VoiceCat.Interop` (`dotnet build`) succeeded. **Windows App not built** (WinForms
|
||||
net10.0-windows can't build on macOS) — changes follow existing patterns; needs a Windows
|
||||
build + manual check.
|
||||
- **Next (manual):** on each client, set PTT + non-default VAD/mic-gain, relaunch → settings
|
||||
restored; boost a quiet mic and confirm others hear it louder; iOS send a channel message;
|
||||
iOS VoiceOver → focus a user → actions rotor opens tuning.
|
||||
|
||||
- **Done (2026-06-22):** **Fixed growing voice latency (jitter-buffer depth ratchet).** Symptom:
|
||||
end-to-end latency grew to multiple seconds and "drifted backward," reset only by leaving/
|
||||
rejoining voice (DTX/FEC/DRED on, 10% loss). Root cause was **not** the codec settings (10% loss
|
||||
is just an `OPUS_SET_PACKET_LOSS_PERC` encoder hint; FEC/DRED add no standing latency) but the
|
||||
receiver playout logic in `core/src/audio/audio_engine.cpp`: the playout clock free-ran in real
|
||||
time while the sender omitted silence from its timestamps and set **no header flags at all**, and
|
||||
the only correction snapped the clock to the *oldest* buffered frame (could only *add* latency) —
|
||||
with `target_depth_ms_` computed but never enforced, so latency could only grow or be reset.
|
||||
**Fix:** bounded-depth playout — (re)seed to the *leading edge* (newest frame) on start/marker/
|
||||
starve, and **frame-skip catch-up** that trims a backlog beyond `target + hysteresis` (the missing
|
||||
downward force). Plus hardening: adaptive late-drop window, talkspurt `kFlagMarker`/`kFlagDtx`
|
||||
now actually stamped by the sender (`client.cpp` send path) and consumed on recv, EWMA outlier
|
||||
rejection (silence gaps/stragglers no longer poison the estimate), duplicate counting, ring-
|
||||
underrun diagnostics (`stream_underruns`/`stream_duplicates`). New regression test
|
||||
`tests/test_jitter_depth.cpp` asserts depth stays bounded (<200 ms) while arrivals outrun playout
|
||||
for ~4 s. `ctest --preset dev` green — **27/27**. Docs: `docs/voice.md` §5 rewritten.
|
||||
- **Next (manual E2E):** two clients in a channel, DTX/FEC/DRED on — talk in alternating bursts
|
||||
for several minutes and confirm latency stays low/stable (no backward drift, no rejoin needed).
|
||||
|
||||
- **Windows done / Apple awaiting Mac build (2026-06-22):** **Event sound effects + optional
|
||||
text-to-speech for all clients.** Clients now play a cue per session event and can optionally
|
||||
speak it (TTS off by default; when on it announces joins/leaves and reads message/PM bodies).
|
||||
One canonical event→sound mapping (defined off the shared C ABI `vc_event` stream) is mirrored
|
||||
across all three clients; `self` vs others is `user_id == self_user_id`, and outgoing messages
|
||||
echo back as events so sent/recv cues need no separate send-path hook. Conservative defaults
|
||||
(join/leave, channel/PM sent+recv, login, logout, connection-lost, mic on/off ON; per-utterance
|
||||
self voice-activity `va_start/va_stop` and the PTT cue OFF). WAVs ship from `assets/sounds/`.
|
||||
- **Windows (built + verified):** new `VoiceCat.App/Notifications/` (`FeedbackSettings` →
|
||||
`%AppData%\VoiceCat\feedback.json`, `SoundPlayerPool` via `System.Media.SoundPlayer`,
|
||||
`SpeechAnnouncer` via the **Prismatoid** NuGet 0.3.0, `EventFeedback` dispatcher); hooks in
|
||||
`Forms/MainForm.cs`; `Forms/NotificationSettingsForm.cs` under a new **Settings ▸ Notifications**
|
||||
menu. `.csproj` adds the Prismatoid PackageRef and copies the WAVs into `sounds\`. `dotnet build`
|
||||
clean; WAVs + `Prismatoid.dll` confirmed in output. Note: `SoundPlayer` has no gain control, so
|
||||
volume is honoured as a mute gate (0 = silent) — swap to NAudio if finer/overlap control is needed.
|
||||
- **macOS + iOS (written, NOT yet built — needs a Mac):** shared `Sources/VoiceCatCore/Feedback/`
|
||||
(`SoundEvent`, `EventFeedback` = `AVAudioPlayer` pool + native `AVSpeechSynthesizer`,
|
||||
`FeedbackSettings` over `UserDefaults`); WAVs copied into `Sources/VoiceCatCore/Sounds/` and
|
||||
bundled via `Package.swift` `resources: [.process("Sounds")]` (`Bundle.module`). Hooks: iOS
|
||||
`SessionState.handleEvent` (+ split `userJoined`/`userLeft`, added a `.disconnected` cue case),
|
||||
`AppState` auth-success login cue, PTT cue in `setPushToTalk`; macOS `MainWindowController`
|
||||
handlers + NSEvent PTT monitor. Settings UI: iOS `SettingsView` Notifications section
|
||||
(`@AppStorage`), macOS `SettingsWindowController` checkboxes + volume slider. No `.pbxproj`
|
||||
edits needed (shared files are SPM-managed; app files already in the projects).
|
||||
- **Next:** on a Mac, `clients/apple/scripts/build-xcframework.sh --all` then build
|
||||
VoiceCatMac/VoiceCatiOS; fix compile fallout. **Watch the iOS audio session:** cues/TTS play over
|
||||
the live VPIO `playAndRecord` session — verify they mix and don't duck/interrupt the call or get
|
||||
silenced by the mute switch (most likely bug site). Then run `ctest --preset dev` (unchanged —
|
||||
no core/server code touched).
|
||||
|
||||
- **Done (2026-06-22):** **UDP media now shares the TCP port (self-host port-forward fix).** Symptom: a
|
||||
remote self-hosted server (`iamtalon.me:8384`, TCP+UDP 8384 forwarded) accepted TCP connections but
|
||||
passed no voice. Root cause: `Config::media_port` defaulted to `0` = OS-assigned, and `main.cpp`'s
|
||||
`--port` only set `bind_port` (TCP) — so the UDP relay bound a *random high port*, advertised it to
|
||||
clients in HELLO (`udp_port`), and clients sent voice there. With only `8384/udp` forwarded those
|
||||
packets were dropped → connect OK, no audio. This contradicted `docs/deployment.md` ("Control and media
|
||||
share one port number on TCP+UDP"). **Fix (`server/src/server.cpp`):** media follows bind_port when
|
||||
`media_port == 0` — `media_want = cfg_.media_port != 0 ? cfg_.media_port : cfg_.bind_port`. The
|
||||
`0 = OS-assigned` escape hatch survives when `bind_port` is also 0, so tests that bind ephemeral ports
|
||||
are unaffected (kept the logic in server.cpp rather than hardcoding 8384 as the default, which would
|
||||
collide parallel tests on UDP 8384). Banner now reads `TCP :8384 UDP :8384`. Build + `ctest --preset
|
||||
dev` green (24/24); live-verified banner with `--port 8390` → `UDP :8390`. **Action for self-hosters:**
|
||||
redeploy and confirm the startup banner shows matching TCP/UDP ports; the existing single forward rule
|
||||
is now correct. If voice still fails, watch the server's rate-limited `[media] dropped frames —
|
||||
unmapped-endpoint=…` line (NAT source-port rewrite would be the next suspect).
|
||||
|
||||
- **Done (2026-06-23, Swift-only — no core/ABI change; awaiting on-device verification):** **iOS audio
|
||||
stack unified — one always-external `AVAudioEngine`, miniaudio dropped on iOS.** The iOS audio path was
|
||||
a fragile hybrid: Voice-Chat-class presets ran a native VPIO `AVAudioEngine` (core external) while
|
||||
Stereo/Studio/A2DP presets ran the core's miniaudio devices. Nearly every bug lived in the seam
|
||||
(lingering miniaudio capture unit fighting VPIO, the `audioRestart` ordering dance, the route-change
|
||||
"glitching" loop, stereo↔mono stickiness, "can't hear anyone"), and switching presets/routes mid-call
|
||||
routinely dropped input, output, or both. **Fix: drive *all* iOS audio through one `AVAudioEngine` with
|
||||
the core fully external at all times** — `vc_set_external_playback(1)` once at connect, every MIC stream
|
||||
`external_feed=1`, mic via `vc_stream_feed_pcm`, playback via `vc_set_mixed_output_sink`.
|
||||
- `IOSVoiceProcessingEngine.swift` → **`IOSAudioEngine`** (same file): always-on `AVAudioSourceNode`
|
||||
playback (runs whenever connected, so remote audio plays before you join voice); conditional mic tap;
|
||||
VPIO + AGC toggled per config. One private `rebuild()` (stop → set VPIO → install tap → start) backs
|
||||
`startListening`/`stop`/`startMic`/`stopMic`/`reconfigure`/`setCaptureChannels`. Kept the `PCMRing`
|
||||
and ring-stats diagnostics.
|
||||
- `IOSAudioRouter`: presets cut from seven to **four** — Voice Chat (VPIO mono, system output),
|
||||
Stereo Mic / Mono Mic (internal built-in mic regardless of output, A2DP-capable, no VPIO), Advanced
|
||||
(manual). New persisted `voiceProcessingEnabled` (master AEC+NS) + `agcEnabled`; setters now call
|
||||
`IOSAudioEngine.reconfigure()` instead of `client.audioRestart()` + `reconcileVoicePath`. Kept the
|
||||
proven AVAudioSession recipes (category/mode/options, stereo capsule, `applyA2dpSpeakerFallback`).
|
||||
- `AudioSessionManager` slimmed (drops `client`/`activeMicStreamId`/`reconcileVoicePath`; adds
|
||||
`isActive`); interruption-end & device-change now `reconfigure()` the engine. `SessionState`
|
||||
`doStartMicStream`/`stopMicStream` collapsed to start-stream + `startMic`/`stopMic` (no
|
||||
`setExternalPlayback`/`audioRestart` toggling); `reconcileVoicePath` deleted. `AppState` sets external
|
||||
playback + `startListening` at connect, `stop()` at disconnect. `SettingsView` → four presets +
|
||||
Advanced VPIO/AGC toggles.
|
||||
- **No core/ABI/test change** — relies on the already-shipped `vc_set_external_playback` /
|
||||
`external_feed` / `vc_set_mixed_output_sink` / `vc_stream_feed_pcm` path (`test_external_pcm`,
|
||||
`test_external_playback`). `xcodebuild` iOS device Debug **BUILD SUCCEEDED**. **Rebuild the
|
||||
xcframework is NOT required** (no new symbols).
|
||||
- **Next (user, on device):** two iPhones in a channel — verify BOTH directions survive every
|
||||
transition and are never silent unless intended: Voice Chat (no echo, NR), listen-only before joining,
|
||||
join↔leave repeatedly, switch Voice Chat↔Stereo↔Mono↔Advanced *while in voice*, A2DP connect/unplug,
|
||||
wired connect/unplug, phone-call interruption + resume, screen-audio share.
|
||||
|
||||
- **Superseded by the 2026-06-23 unification above (2026-06-22):** **iOS real echo cancellation / noise
|
||||
suppression via native VPIO.** Root cause of "voice chat doesn't sound like a call" (echo + no NR): real iOS
|
||||
AEC/NS/AGC come only from Apple's Voice-Processing I/O unit (VPIO), but the core uses miniaudio's
|
||||
plain RemoteIO units — so `.voiceChat` mode alone never engaged AEC. Fix moves both mic capture and
|
||||
playback to a native Swift `AVAudioEngine` (`setVoiceProcessingEnabled`) on the AEC presets, with the
|
||||
core in external mode.
|
||||
- **Core (done, builds + tests green):** new ABI `vc_set_mixed_output_sink` + `vc_set_external_playback`
|
||||
(voicecat.h PATCH→2). `AudioEngine` gains a mixer-timer thread that drives `on_playback` (decode+mix)
|
||||
on a ~20 ms cadence with NO hardware playback device and ships the final mix to the mixed-output
|
||||
sink; `start()` also skips the hardware capture device when the MIC stream is `external_feed`
|
||||
(`AudioParams.external_capture`). New white-box test `test_external_playback` (23/24;
|
||||
pre-existing `external_pcm` teardown crash on Darwin 25.5 is UNRELATED — original tree crashes too).
|
||||
- **Swift (done, builds):** `VoiceCatCore` wrappers (`externalFeed` on `StreamDescriptor`,
|
||||
`setMixedOutputSink`, `setExternalPlayback`); new `IOSVoiceProcessingEngine.swift` (VPIO
|
||||
`AVAudioEngine`: mic tap→`feedPcm`, mixed-sink lock-free ring→`AVAudioSourceNode`);
|
||||
`IOSAudioRouter.currentConfigUsesVoiceProcessing` gates the path per preset; `SessionState`
|
||||
join/leave + `reconcileVoicePath()` switch between VPIO and the miniaudio path; Voice Chat defaults
|
||||
to speaker; SettingsView shows AEC/NS state. **Rebuild the xcframework** before building the app:
|
||||
`clients/apple/scripts/build-xcframework.sh --all` (new ABI symbols). `xcodebuild` iOS sim Debug
|
||||
BUILD SUCCEEDED.
|
||||
- **Post-verification fixes (2026-06-22, Swift-only — no core/ABI change):** two on-device bugs fixed.
|
||||
- **Voice Chat (VPIO) silent playback:** `SessionState.doStartMicStream()` called `audioRestart()`
|
||||
BEFORE `startStream`, so when the engine was already running (a remote stream had started it) it
|
||||
reopened with `external_capture=false` and opened a hardware miniaudio capture device; the
|
||||
announce-result restart then early-returned (engine already running) so that device was never
|
||||
dropped and fought the `AVAudioEngine` VPIO unit, silencing playback. Fix: set
|
||||
`setExternalPlayback` first, then `startStream` (which stores `external_feed` synchronously), THEN
|
||||
`audioRestart()` — the core reopens in full external mode (no hardware devices). Added VPIO
|
||||
diagnostics (graph/route formats at start; ring written/read totals at teardown).
|
||||
- **Stereo Mic / Studio quiet earpiece:** the `.builtInMicBtA2dp` presets omit `.defaultToSpeaker`
|
||||
(it breaks A2DP) and skip `forceSpeaker`, so with no Bluetooth connected output pinned to the quiet
|
||||
receiver. New `IOSAudioRouter.applyA2dpSpeakerFallback()` overrides to the built-in speaker when no
|
||||
external (A2DP/wired/AirPlay) output is present, clears the override when one is — called after
|
||||
activation and on device-change route changes (`AudioSessionManager`).
|
||||
- **Next (user, on device):** two iPhones on speaker, Voice Chat preset → confirm (a) no echo, (b)
|
||||
background noise suppressed, (c) speaker output by default AND remote audio is now audible; then
|
||||
Stereo Mic / Studio with no BT → confirm loud speaker (not earpiece), and A2DP takes over when a BT
|
||||
headset connects. Tune the mixer-timer/ring sizing if there's under/overrun.
|
||||
|
||||
- **Done (2026-06-21):** **Docker + Linux deployment + GitHub Actions cross-build.** Added the complete Linux server
|
||||
deployment story (the only missing platform — Windows and macOS already have native
|
||||
binaries):
|
||||
- `Dockerfile` — multi-stage (builder: `ubuntu:24.04` + vcpkg + `cmake --preset
|
||||
server-release`; runtime: `ubuntu:24.04`, non-root `voicecat` user, `/data` volume,
|
||||
TCP+UDP 8384). vcpkg is fetched via the GitHub archive tarball at the exact
|
||||
`builtin-baseline` commit (`d46283cf…`), avoiding a full git-history clone. BuildKit
|
||||
cache mounts on `/vcpkg/downloads`, `/vcpkg/buildtrees`, `/vcpkg/packages` (scoped by
|
||||
`TARGETARCH`) keep rebuilds fast. Both `voicecat-server` and `voicecat-admin` are
|
||||
copied into the runtime image.
|
||||
- `docker-compose.yml` — single-service compose file with `restart: unless-stopped`,
|
||||
named volume `voicecat-data`, and port mappings for TCP+UDP 8384. `command:` shows
|
||||
how to set `--name`.
|
||||
- `.dockerignore` — excludes `.git/`, `build/`, `clients/` (Swift/C# code), `docs/`,
|
||||
markdown, editor config; build context is just `core/`, `server/`, `tools/`, `cmake/`,
|
||||
and the three root CMake/vcpkg files.
|
||||
- `deploy/linux/voicecat.service` — hardened systemd unit (non-root, `ProtectSystem`,
|
||||
`NoNewPrivileges`, `AmbientCapabilities=CAP_NET_BIND_SERVICE`) for bare-metal deploys.
|
||||
- Multi-arch: `docker buildx build --platform linux/amd64,linux/arm64 .` works without
|
||||
any triplet override — `cmake/voicecat-toolchain.cmake` auto-detects from the host
|
||||
arch cmake sees inside the buildx container.
|
||||
- Quick start: `docker compose up -d` (or `docker run -d -p 8384:8384/tcp -p
|
||||
8384:8384/udp -v voicecat-data:/data voicecat`). First run auto-generates identity
|
||||
+ cert + DB; check logs for fingerprint + admin password.
|
||||
- **GitHub Actions** (`.github/workflows/build-linux.yml`): primary cross-platform
|
||||
binary build path — amd64 uses `ubuntu-24.04`, arm64 uses `ubuntu-24.04-arm`
|
||||
(native, not QEMU). Triggers on push to main (when C++/cmake files change) and
|
||||
manually via `workflow_dispatch`. Downloads land as 90-day artifacts.
|
||||
`scripts/build-linux-binaries.sh` is the local Docker fallback (needs ~10–15 GB
|
||||
free disk; suits Linux dev machines, not Windows Docker Desktop).
|
||||
|
||||
- **Done (2026-06-21):** **Fix permanent voice-loss bug + harden the UDP media path (protocol v2).**
|
||||
Field report: two iOS users lost all audio mid-call after a bad-network blip and could not
|
||||
recover even by restarting the apps. Root causes found in the UDP media path:
|
||||
1. **Anti-replay window poisoned by unauthenticated packets (the trigger).**
|
||||
`SodiumMediaCrypto::open()` advanced `recv_highest_` from the plaintext header `seq`
|
||||
*before* verifying the AEAD tag and never rolled it back on failure. One corrupted/forged
|
||||
frame (a bit-flip on flaky wifi) shoved the high-water mark far ahead, after which every
|
||||
legitimate frame was rejected as "too old" — permanently. Fixed by reordering to
|
||||
replay-check → authenticate → update (RFC 3711 §3.3): the window is now touched only after
|
||||
a successful tag check. Regression test in `test_media_aead.cpp`
|
||||
(`test_corrupted_seq_does_not_poison_window`) — fails on the old code, passes now.
|
||||
2. **16-bit seq wrap with no rollover counter.** The wire header carried only the low 16 bits
|
||||
of the nonce counter (zero-extended on receive); after 65,536 frames the reconstructed
|
||||
nonce diverged and all frames failed auth. **Wire format widened to a full u64 seq**
|
||||
(`voice_frame.h`: header 14 → 20 bytes, `seq` u16 → u64; `crypto.cpp`, `client.cpp`,
|
||||
`media_relay.cpp` updated; `JitterBuffer::Frame::seq` widened). This is a **versioned wire
|
||||
change → `VOICECAT_PROTOCOL_VERSION` 1 → 2**; the `Hello` handshake rejects on mismatch
|
||||
(`conn_session.cpp`). The voice frame is parsed only in `core/`+`server/`+`tests/`, so the
|
||||
Swift/C# clients need only a rebuild — no parser changes.
|
||||
3. **Server leaked UDP state on disconnect.** `SessionRegistry::unregister_session()` now also
|
||||
frees `udp_endpoints_`/`udp_tokens_`/`ssrc_to_session_` (scan-and-erase by session id).
|
||||
4. **Diagnostics.** `MediaRelay` now emits rate-limited dropped-frame counters
|
||||
(unmapped-endpoint / no-recv-crypto / open-failed) so a wedged media path is observable.
|
||||
- **Verified:** `cmake --build --preset dev` clean; `ctest --preset dev -E external_pcm`
|
||||
**22/22 pass** (incl. `m2_voice` e2e relay + the two new AEAD regressions). `external_pcm`
|
||||
still aborts on the **pre-existing** CoreAudio shutdown mutex race (confirmed identical on a
|
||||
clean baseline checkout under the same harness — unrelated to these changes). Docs updated:
|
||||
`voice.md` §2 (header), `protocol.md` (v2 + negotiation), `security.md` (authenticate-then-advance).
|
||||
|
||||
- **Done (2026-06-21):** **Expose all channel codec params + guest nickname in every client.**
|
||||
- **DRED everywhere + ABI fix.** `dred` (Opus 1.6 Deep REDundancy) existed in the C ABI
|
||||
(`vc_audio_config.dred`) and proto but was absent from *both* client marshaling layers — a
|
||||
latent ABI mismatch: Swift `AudioConfig` and the C# `VcAudioConfigNative` blittable struct
|
||||
were each one `int` short of the native struct passed to `vc_create_channel`/`vc_edit_channel`.
|
||||
Added `dred` through Swift (`Models.swift`, `Marshaling.swift`, `VoiceCatClient.toNative`) and
|
||||
C# (`Structs.cs`, `Models.cs`, `Marshaling.cs`, `VoiceCatClient.cs`).
|
||||
- **Windows:** added the one missing DRED checkbox to `ChannelEditDialog` (all other params
|
||||
were already present).
|
||||
- **macOS:** `ChannelEditSheet` now exposes the previously-hidden params — application profile,
|
||||
sample rate, expected packet loss, complexity, and DRED (was only stereo/bitrate/frame/FEC/DTX).
|
||||
- **iOS:** `ChannelEditView` was name+topic only; rebuilt into a full create **and edit** form
|
||||
(General: name/topic/parent/password/max-users/sort-order; Audio: stereo/bitrate/sample-rate/
|
||||
frame/application/packet-loss/complexity/FEC/DTX/DRED). Added `SessionState.editChannel` and an
|
||||
"Edit" swipe action (admins) in `ChannelTreeView` + `ChannelBrowserView` (iOS previously had no
|
||||
edit-channel UI at all). Note: the channel list doesn't carry the current audio config, so on
|
||||
edit the audio fields start from codec defaults — same limitation as macOS/Windows.
|
||||
- **Guest nickname.** Guests could not set a display name on iOS *or* macOS (the field was
|
||||
absent/disabled; only Windows had it). Added a dedicated `nickname` to `SavedServer` on both
|
||||
(backward-compatible Codable), a Nickname field shown in Guest mode (`AddServerView` /
|
||||
`AddServerSheet`), and wired the guest auth path to use it (`AppState`, `ConnectWindowController`).
|
||||
- **Verified:** `xcodebuild` Debug — macOS BUILD SUCCEEDED; iOS (sim, `ARCHS=arm64`) BUILD
|
||||
SUCCEEDED. Core `ctest --preset dev` 22/23 (only `external_pcm` aborts on a pre-existing
|
||||
shutdown mutex race; no C++ was changed). Windows C# not buildable on macOS — changes reviewed.
|
||||
|
||||
- **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
|
||||
@@ -24,6 +630,21 @@ up instantly. Newest status at the top.
|
||||
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-22):** **Windows exclude mode is now a real native exclude + self-echo
|
||||
removal.** The "All apps except selected" mode previously captured the *complement of a frozen
|
||||
app snapshot* in INCLUDE mode (missed late-launched apps, system sounds; wasted captures on
|
||||
silent windows). It now opens a **single `ProcessLoopbackCapture` in EXCLUDE mode**
|
||||
(`AUDIOCLIENT_PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE`) of the one chosen app — true
|
||||
system-mix-minus-one, dynamic. `AppAudioPickerDialog` enforces single-selection in exclude
|
||||
mode (the API takes one target PID). Added an **"Exclude VoiceCat's own audio (prevents echo)"**
|
||||
checkbox (default on, entire-desktop only) that routes the desktop capture through the same
|
||||
EXCLUDE path targeting `Environment.ProcessId`, killing the whole-device self-echo loop.
|
||||
Touched `ProcessAudioMixer.cs` (`ResolveCaptures`), `AppAudioPickerDialog.cs`, `MainForm.cs`,
|
||||
`AudioSessionEnumerator.cs` (`EntireDesktop(bool ExcludeSelf)`); docs in voice.md §9. No C++ /
|
||||
ABI changes. `dotnet build` clean. **Still to verify on-device:** exclude actually silences
|
||||
the chosen app while the rest plays, late-launched apps appear without restart, and the
|
||||
self-exclude checkbox removes the echo.
|
||||
|
||||
- **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
|
||||
@@ -36,6 +657,18 @@ up instantly. Newest status at the top.
|
||||
aborts at shutdown (`mutex lock failed`), a **pre-existing** teardown crash unrelated to this
|
||||
change (no C++ was modified).
|
||||
|
||||
- **Done (2026-06-21):** **macOS per-app screen-audio selection.** Before sharing, a new
|
||||
`ScreenSharePickerSheet` lets the user choose scope — share Everything / Only selected apps /
|
||||
All except selected apps — plus a first-class **"Exclude screen reader (VoiceOver) audio"**
|
||||
toggle. `ScreenAudioCapture` now takes a `ScreenAudioSelection` and builds the matching
|
||||
`SCContentFilter` (`including:` / `excludingApplications:`); app list comes from
|
||||
`SCShareableContent`. macOS `xcodebuild` Debug BUILD SUCCEEDED. iOS deliberately untouched —
|
||||
ReplayKit only delivers the mixed system stream, so per-app/VoiceOver filtering is impossible
|
||||
there (documented in voice.md §9). **Still to verify on-device:** which process actually
|
||||
carries VoiceOver speech (VoiceOver app vs. `com.apple.speech.speechsynthesisd`) — the exclude
|
||||
set covers both candidates in `ScreenAudioCapture.screenReaderBundleIDs`; confirm exclusion
|
||||
actually silences it in a real share.
|
||||
|
||||
- **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
|
||||
@@ -245,7 +878,33 @@ up instantly. Newest status at the top.
|
||||
|
||||
## Recent completed work
|
||||
|
||||
All items below are `[x]` done; `ctest --preset dev` 23/23 on Windows after all.
|
||||
All items below are `[x]` done; `ctest --preset dev` 26/26 on Windows after all.
|
||||
|
||||
- **Per-channel sample_rate as a bandwidth cap** (2026-06-22): the channel `sample_rate` field
|
||||
was inert (the codec is pinned to 48 kHz). Made it meaningful without changing the 48 kHz
|
||||
clock: it's carried as `OpusParams::max_bandwidth_hz` and applied via `OPUS_SET_MAX_BANDWIDTH`
|
||||
in `OpusEncoder::init` (8000→narrowband … 48000→full). Made it **channel-authoritative** on
|
||||
the server (`conn_session.cpp` no longer overrides effective `sample_rate` with the client's
|
||||
always-48000 request — like `frame_ms`/`mode`). `vc_get_stream_audio_config` now reports the
|
||||
channel's configured rate for own streams too. New ctest `channel_samplerate`: a 7 kHz tone is
|
||||
attenuated ~1000× on an 8 kHz channel vs a 48 kHz channel. Files: `opus_codec.{h,cpp}`,
|
||||
`client.cpp`, `server/src/conn_session.cpp`, `docs/voice.md`, `tests/test_channel_samplerate.cpp`,
|
||||
`tests/CMakeLists.txt`. (Future: a true non-48k stack is possible but unnecessary — 48 kHz is
|
||||
what nearly all hard/software runs at; the bandwidth cap covers the narrowband use case.)
|
||||
|
||||
- **Non-20ms channel frame_ms fix** (2026-06-22): the AudioEngine capture clock is fixed at
|
||||
48 kHz / 20 ms (960-sample frames), but a channel may set any Opus `frame_ms` (2.5…60 ms,
|
||||
docs/voice.md §3) and the server enforces it unclamped. The send path handed the engine's
|
||||
960-sample frame straight to an encoder configured for the channel's window — silently
|
||||
ignoring `frame_ms > 20` and **breaking `frame_ms < 20` entirely** (receiver sized its decode
|
||||
buffer too small → `OPUS_BUFFER_TOO_SMALL` → dead audio). Affected the hardware mic AND
|
||||
`vc_stream_feed_pcm`. Fix: `vc_client::on_capture_frame` now reframes each captured/fed block
|
||||
to `ls.frame_samples` via a per-`LocalStream` accumulator (pre-sized at announce, no RT-thread
|
||||
alloc) before `encode_and_send_frame`; the 20 ms case stays a zero-copy fast path. Also pinned
|
||||
the codec to 48 kHz internally in `opus_params_from_audio_config` (was honoring a non-48k
|
||||
effective sample_rate against a 48k PCM clock). New ctest `frame_ms_reframe` (40 ms accumulate
|
||||
+ 10 ms split round trips). Files: `client.{h,cpp}`, `voicecat.h` (feed doc), `docs/voice.md`,
|
||||
`tests/test_frame_ms_reframe.cpp`, `tests/CMakeLists.txt`.
|
||||
|
||||
- **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.
|
||||
@@ -411,7 +1070,8 @@ text, device pickers, level meter on each platform.
|
||||
|
||||
**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.
|
||||
PerUserTuningDialog, PttKeyCaptureDialog), `VoiceCat.Interop.Tests`. PTT can be system-wide
|
||||
(Raw Input / WM_INPUT) or focus-scoped, toggled in Audio settings (default system-wide).
|
||||
|
||||
**macOS** (`clients/apple/macOS/VoiceCatMac.xcodeproj`): NSOutlineView channel tree,
|
||||
NSTableView user list, NSTextView chat, voice controls, full VoiceOver accessibility, admin
|
||||
@@ -449,6 +1109,14 @@ iOS 18.0 deployment target. App Group `group.cat.voice.VoiceCat` for Keychain sh
|
||||
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}`.
|
||||
- [x] **In-band FEC decoder wiring** — done (2026-06-22). The encoder set `OPUS_SET_INBAND_FEC`
|
||||
all along, but the decoder never invoked it — the loss path went DRED → PLC, so FEC redundancy
|
||||
was emitted (and paid for in bitrate) but never consumed. Wired the FEC recovery into
|
||||
`AudioEngine::on_playback`'s loss branch between DRED and PLC: copy the next buffered packet
|
||||
once, try DRED, else (if the stream negotiated FEC) `decode(next_pkt, …, fec=true)`, else PLC.
|
||||
Added per-stream `RemoteStream::fec_enabled_`, captured from `OpusParams` in
|
||||
`init_recv_stream`. Recovery priority is now **DRED → FEC → PLC**. ctest 27/27 green. Files:
|
||||
`audio_engine.{h,cpp}`, `docs/voice.md`.
|
||||
- [ ] **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
|
||||
|
||||
@@ -23,9 +23,9 @@ The default development preset is **`dev`** — it builds everything (server + t
|
||||
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
|
||||
# one-time vcpkg setup (bundled as a submodule, pinned to vcpkg.json's builtin-baseline):
|
||||
git submodule update --init vcpkg
|
||||
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||
|
||||
# configure + build + test:
|
||||
cmake --preset dev
|
||||
@@ -33,6 +33,9 @@ cmake --build --preset dev
|
||||
ctest --preset dev # 21 behavior tests
|
||||
```
|
||||
|
||||
To use an external vcpkg checkout instead, set `VCPKG_ROOT=/path/to/vcpkg` (or
|
||||
`$env:VCPKG_ROOT` on PowerShell) — it always takes priority over the bundled submodule.
|
||||
|
||||
Artifacts land in `build/dev/bin/` (`voicecat-server`, `vccli`, `voicecat-admin`).
|
||||
|
||||
The `skeleton` preset (no vcpkg deps, stubs only) is a fast smoke check that needs no
|
||||
|
||||
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.
@@ -40,7 +40,11 @@ let package = Package(
|
||||
.target(
|
||||
name: "VoiceCatCore",
|
||||
dependencies: ["VoiceCatCoreXCF"],
|
||||
path: "Sources/VoiceCatCore"
|
||||
path: "Sources/VoiceCatCore",
|
||||
// Event-cue WAVs (shared with the Windows client) bundled into the package's
|
||||
// resource bundle; EventFeedback loads them via Bundle.module. Copied from
|
||||
// assets/sounds/ into Sources/VoiceCatCore/Sounds/.
|
||||
resources: [.process("Sounds")]
|
||||
),
|
||||
// Smoke tests against a real voicecat-server — mirrors clients/windows/
|
||||
// VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs. Requires the `dev` CMake preset
|
||||
|
||||
+35
-5
@@ -106,12 +106,16 @@ scripts/build-xcframework.sh --all
|
||||
|
||||
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
|
||||
spdlog, asio, abseil, …) are 107 separate `.a` files under `vcpkg_installed/arm64-osx/lib/`,
|
||||
and the vendored RNNoise noise-suppression lib (`third_party/rnnoise/`, built as a CMake
|
||||
target → `build/<preset>/lib/librnnoise.a`) is another. A Swift Package binary target can
|
||||
only link ONE `.a` per XCFramework slice, so `build-xcframework.sh` merges them all — vcpkg
|
||||
deps plus the locally-built vendored libs — into a single self-contained `libvoicecat-fat.a`
|
||||
(~33 MB) using `libtool -static`. This is the Apple equivalent of how the Windows client
|
||||
ships a single `voicecat.dll` with all deps statically linked (via MinGW's `-static` flags
|
||||
in [`core/CMakeLists.txt`](../../core/CMakeLists.txt)).
|
||||
in [`core/CMakeLists.txt`](../../core/CMakeLists.txt)). If you add another vendored (non-vcpkg)
|
||||
static-lib target to the core, it's picked up automatically as long as it lands in
|
||||
`build/<preset>/lib/` and isn't named `libvoicecat*`.
|
||||
|
||||
### Swift Package
|
||||
|
||||
@@ -128,3 +132,29 @@ The `Package.swift` declares:
|
||||
the fat static lib is C++20, so the final executable must link libc++ (the LLVM C++ standard
|
||||
library on macOS). vcpkg's static deps are already in the `.a`; macOS system frameworks
|
||||
(CoreAudio/CoreFoundation) are auto-discovered by the linker.
|
||||
|
||||
## Ad-hoc distribution (iOS, pre-TestFlight)
|
||||
|
||||
To hand the iOS app to a handful of friends before TestFlight, use
|
||||
[`scripts/dist-ios-adhoc.sh`](../../scripts/dist-ios-adhoc.sh). It registers each device's
|
||||
UDID, builds an ad-hoc-signed `VoiceCatiOS.ipa`, and generates the `manifest.plist` +
|
||||
`index.html` for an over-the-air (`itms-services://`) web install. Ad-hoc builds only run on
|
||||
devices whose UDID is registered *before* signing, and stock iOS won't install a bare `.ipa`
|
||||
without a sideloading tool — so the web-install page is the friend-friendly path.
|
||||
|
||||
```bash
|
||||
# One-time: create an App Store Connect API "Team Key" (.p8, Admin/App Manager access) at
|
||||
# App Store Connect → Users and Access → Integrations → App Store Connect API
|
||||
export ASC_KEY_ID=ABC123 ASC_ISSUER_ID=1111-... ASC_KEY_PATH=~/.appstoreconnect/AuthKey_ABC123.p8
|
||||
|
||||
# Register a device + build + stage everything into dist/ios-adhoc/
|
||||
scripts/dist-ios-adhoc.sh --udid <UDID> --name "Friend iPhone" \
|
||||
--base-url https://example.com/voicecat
|
||||
```
|
||||
|
||||
Then upload the three staged files (`VoiceCatiOS.ipa`, `manifest.plist`, `index.html`) to
|
||||
that **HTTPS** folder and open `index.html` in Safari on a registered iPhone (iOS 18+).
|
||||
Device UDID registration is automated via [`scripts/asc_api.py`](../../scripts/asc_api.py)
|
||||
(`asc_api.py list` shows the registered devices against the 100-iOS-devices/year cap).
|
||||
Requires a paid Apple Developer Program membership. Run `scripts/dist-ios-adhoc.sh --help`
|
||||
for all flags.
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
// 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.
|
||||
// C callbacks use an unretained `user` context. Client destruction joins callback threads,
|
||||
// and transient event pointers are copied before the callback returns.
|
||||
|
||||
import VoiceCatC
|
||||
import Foundation
|
||||
|
||||
@@ -61,7 +61,7 @@ public enum VoiceCatConnectionState: UInt32, Sendable, Equatable {
|
||||
case tlsHandshake = 2
|
||||
case authenticating = 3
|
||||
case connected = 4
|
||||
/// M4: handshake succeeded, waiting on `confirmServerIdentity()`.
|
||||
/// Handshake succeeded, waiting on `confirmServerIdentity()`.
|
||||
case verifyingIdentity = 5
|
||||
|
||||
public init(_ cValue: vc_connection_state) {
|
||||
@@ -125,14 +125,16 @@ public enum VoiceCatEventType: UInt32, Sendable, Equatable {
|
||||
case talkState = 9
|
||||
case error = 10
|
||||
case disconnected = 11
|
||||
/// M4: reply to `joinChannel()` — see `VoiceCatEvent.result` / `.channelId`.
|
||||
/// Reply to `joinChannel()` — see `VoiceCatEvent.result` / `.channelId`.
|
||||
case joinResult = 12
|
||||
/// M4: the TOFU server-identity gate — see `VoiceCatEvent.tofuStatus` / `.text`.
|
||||
/// The TOFU server-identity gate — see `VoiceCatEvent.tofuStatus` / `.text`.
|
||||
case serverIdentity = 13
|
||||
/// M5: async result for moderation/admin/channel operations.
|
||||
/// Async result for moderation/admin/channel operations.
|
||||
case genericResult = 14
|
||||
/// M5: reply to `requestAccountList()` — call `listAccounts()` to read.
|
||||
/// Reply to `requestAccountList()` — call `listAccounts()` to read.
|
||||
case accountList = 15
|
||||
/// Voice-plane subscription state. `u32a` = 1 (subscribed) or 0 (unsubscribed).
|
||||
case voiceState = 16
|
||||
|
||||
public init(_ cValue: vc_event_type) {
|
||||
self = VoiceCatEventType(rawValue: cValue.rawValue) ?? .error
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// EventFeedback — shared audible + spoken feedback for session events, used by both the macOS
|
||||
// (AppKit) and iOS (SwiftUI) clients. Mirrors the Windows client's EventFeedback policy
|
||||
// (clients/windows/.../Notifications/EventFeedback.cs): the platform event handlers decide WHAT
|
||||
// to play (they own the model/nickname/channel context); this type owns the "should I, and how"
|
||||
// policy plus the AVFoundation playback/synthesis.
|
||||
//
|
||||
// Sound effects use AVAudioPlayer; spoken announcements use the OS-native AVSpeechSynthesizer.
|
||||
//
|
||||
// NOTE (iOS): on the voice path the app runs a play-and-record AVAudioSession (VPIO). Playing
|
||||
// these cues / speech over that session can interact with the live call (ducking, route, or the
|
||||
// mute switch). The session category should allow mixing — verify on device. This is the most
|
||||
// likely place for platform bugs to surface.
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// User preferences for event sounds and spoken feedback, backed by UserDefaults so the macOS
|
||||
/// and iOS settings screens and this player share one source of truth.
|
||||
public struct FeedbackSettings: Sendable {
|
||||
public var sounds: Bool
|
||||
public var speech: Bool
|
||||
public var volume: Float
|
||||
public var selfTalkSounds: Bool
|
||||
public var pttSound: Bool
|
||||
|
||||
static let keySounds = "feedback.sounds"
|
||||
static let keySpeech = "feedback.speech"
|
||||
static let keyVolume = "feedback.volume"
|
||||
static let keySelfTalk = "feedback.selfTalk"
|
||||
static let keyPtt = "feedback.ptt"
|
||||
|
||||
/// Default values registered with UserDefaults (so "unset" reads as the intended default
|
||||
/// rather than false/0).
|
||||
static let defaults: [String: Any] = [
|
||||
keySounds: true,
|
||||
keySpeech: false,
|
||||
keyVolume: 1.0,
|
||||
keySelfTalk: false,
|
||||
keyPtt: false,
|
||||
]
|
||||
|
||||
/// The current settings, read live from UserDefaults.
|
||||
public static var current: FeedbackSettings {
|
||||
let d = UserDefaults.standard
|
||||
d.register(defaults: defaults) // idempotent — ensures "unset" reads as the intended default
|
||||
return FeedbackSettings(
|
||||
sounds: d.bool(forKey: keySounds),
|
||||
speech: d.bool(forKey: keySpeech),
|
||||
volume: d.float(forKey: keyVolume),
|
||||
selfTalkSounds: d.bool(forKey: keySelfTalk),
|
||||
pttSound: d.bool(forKey: keyPtt))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class EventFeedback {
|
||||
public static let shared = EventFeedback()
|
||||
|
||||
private var players: [SoundEvent: AVAudioPlayer] = [:]
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
|
||||
private init() {
|
||||
UserDefaults.standard.register(defaults: FeedbackSettings.defaults)
|
||||
}
|
||||
|
||||
// MARK: - Sounds
|
||||
|
||||
/// Play an event cue, honouring the user's settings. The two opt-in categories (your own
|
||||
/// voice-activity, and the PTT cue) are gated by their own flags.
|
||||
public func play(_ event: SoundEvent) {
|
||||
let s = FeedbackSettings.current
|
||||
guard s.sounds, s.volume > 0 else { return }
|
||||
if (event == .vaStart || event == .vaStop), !s.selfTalkSounds { return }
|
||||
if event == .ptt, !s.pttSound { return }
|
||||
|
||||
guard let player = player(for: event) else { return }
|
||||
player.volume = s.volume
|
||||
player.currentTime = 0
|
||||
player.play()
|
||||
}
|
||||
|
||||
/// Lazily load and cache an AVAudioPlayer for the event's bundled WAV. Returns nil (silent)
|
||||
/// if the resource is missing or fails to load.
|
||||
private func player(for event: SoundEvent) -> AVAudioPlayer? {
|
||||
if let cached = players[event] { return cached }
|
||||
guard let url = Bundle.module.url(forResource: event.resourceName, withExtension: "wav"),
|
||||
let player = try? AVAudioPlayer(contentsOf: url) else {
|
||||
return nil
|
||||
}
|
||||
player.prepareToPlay()
|
||||
players[event] = player
|
||||
return player
|
||||
}
|
||||
|
||||
// MARK: - Speech
|
||||
|
||||
/// Speak `text` when spoken feedback is enabled. Utterances queue (do not interrupt prior
|
||||
/// speech) so a burst of events is read in order.
|
||||
public func speak(_ text: String) {
|
||||
guard FeedbackSettings.current.speech else { return }
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
synthesizer.speak(AVSpeechUtterance(string: trimmed))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// SoundEvent — the cross-platform set of audible event cues. Each maps to a WAV bundled as an
|
||||
// SPM resource (Sources/VoiceCatCore/Sounds/, copied from assets/sounds/). The same logical set
|
||||
// is mirrored in the Windows client (clients/windows/.../Notifications/SoundEvent.cs) so feedback
|
||||
// stays consistent across platforms.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SoundEvent: CaseIterable, Sendable {
|
||||
case channelJoin // another user joined my channel
|
||||
case channelLeave // another user left my channel
|
||||
case channelRecv // channel text message from someone else
|
||||
case channelSent // channel text message I sent
|
||||
case pmRecv // private message received
|
||||
case pmSent // private message I sent
|
||||
case login // connected / authenticated
|
||||
case logout // clean disconnect
|
||||
case connectionLost // unexpected disconnect
|
||||
case voiceOn // my microphone stream started
|
||||
case voiceOff // my microphone stream stopped
|
||||
case vaStart // my voice-activity began (off by default)
|
||||
case vaStop // my voice-activity ended (off by default)
|
||||
case ptt // push-to-talk engaged (off by default)
|
||||
|
||||
/// Resource name (without extension) as bundled in Sources/VoiceCatCore/Sounds/.
|
||||
var resourceName: String {
|
||||
switch self {
|
||||
case .channelJoin: return "channel_join"
|
||||
case .channelLeave: return "channel_leave"
|
||||
case .channelRecv: return "channel_recv"
|
||||
case .channelSent: return "channel_sent"
|
||||
case .pmRecv: return "pm_recv"
|
||||
case .pmSent: return "pm_sent"
|
||||
case .login: return "login"
|
||||
case .logout: return "logout"
|
||||
case .connectionLost: return "connection_lost"
|
||||
case .voiceOn: return "voice_on"
|
||||
case .voiceOff: return "voice_off"
|
||||
case .vaStart: return "va_start"
|
||||
case .vaStop: return "va_stop"
|
||||
case .ptt: return "ptt"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,8 @@ internal enum Marshaling {
|
||||
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))
|
||||
maxUsers: c.max_users, sortOrder: c.sort_order,
|
||||
audio: audioConfig(c.audio)))
|
||||
}
|
||||
vc_free_channel_list(&list)
|
||||
return result
|
||||
@@ -53,7 +54,8 @@ internal enum Marshaling {
|
||||
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))
|
||||
serverDeafened: u.server_deafened != 0,
|
||||
voiceSubscribed: u.voice_subscribed != 0))
|
||||
}
|
||||
vc_free_user_list(&list)
|
||||
return result
|
||||
@@ -94,7 +96,7 @@ internal enum Marshaling {
|
||||
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)
|
||||
dtx: c.dtx != 0, complexity: c.complexity, dred: c.dred != 0)
|
||||
}
|
||||
|
||||
static func permissions(_ p: vc_permissions) -> Permissions {
|
||||
|
||||
@@ -16,11 +16,17 @@ public struct Channel: Sendable, Equatable, Identifiable {
|
||||
public let passwordProtected: Bool
|
||||
/// 0 = unlimited.
|
||||
public let maxUsers: UInt32
|
||||
public let sortOrder: UInt32
|
||||
/// Authoritative channel Opus params (docs/voice.md §3). Populated from the Channel proto
|
||||
/// so the edit dialog can read back the current config.
|
||||
public let audio: AudioConfig
|
||||
|
||||
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
|
||||
passwordProtected: Bool, maxUsers: UInt32) {
|
||||
passwordProtected: Bool, maxUsers: UInt32, sortOrder: UInt32,
|
||||
audio: AudioConfig) {
|
||||
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
|
||||
self.passwordProtected = passwordProtected; self.maxUsers = maxUsers
|
||||
self.sortOrder = sortOrder; self.audio = audio
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,17 +63,19 @@ public struct User: Sendable, Equatable, Identifiable {
|
||||
public let selfDeafened: Bool
|
||||
public let serverMuted: Bool
|
||||
public let serverDeafened: Bool
|
||||
public let voiceSubscribed: Bool
|
||||
|
||||
public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32,
|
||||
selfMicMuted: Bool, selfDeafened: Bool, serverMuted: Bool,
|
||||
serverDeafened: Bool) {
|
||||
serverDeafened: Bool, voiceSubscribed: Bool) {
|
||||
self.id = id; self.nickname = nickname; self.isGuest = isGuest; self.channelId = channelId
|
||||
self.selfMicMuted = selfMicMuted; self.selfDeafened = selfDeafened
|
||||
self.serverMuted = serverMuted; self.serverDeafened = serverDeafened
|
||||
self.voiceSubscribed = voiceSubscribed
|
||||
}
|
||||
}
|
||||
|
||||
/// Permission bitset — mirrors `vc_permissions` (M5).
|
||||
/// Permission bitset — mirrors `vc_permissions`.
|
||||
public struct Permissions: Sendable, Equatable {
|
||||
public let canCreateTempChannel: Bool
|
||||
public let canKick: Bool
|
||||
@@ -83,7 +91,7 @@ public struct Permissions: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Account entry — mirrors `vc_account` (M5, reply to `listAccounts()`).
|
||||
/// Account entry — mirrors `vc_account` (reply to `listAccounts()`).
|
||||
public struct Account: Sendable, Equatable {
|
||||
public let username: String
|
||||
public let isAdmin: Bool
|
||||
@@ -193,15 +201,16 @@ public struct AudioConfig: Sendable, Equatable {
|
||||
public let expectedPacketLoss: UInt32 // % 0..100
|
||||
public let dtx: Bool
|
||||
public let complexity: UInt32 // 0..10
|
||||
public let dred: Bool // Deep REDundancy (Opus 1.6), off by default
|
||||
|
||||
public init(codec: UInt32 = 0, stereo: Bool = false, sampleRate: UInt32 = 48000,
|
||||
bitrateBps: UInt32 = 64000, frameMs: UInt32 = 20, application: UInt32 = 0,
|
||||
fec: Bool = true, expectedPacketLoss: UInt32 = 5, dtx: Bool = false,
|
||||
complexity: UInt32 = 10) {
|
||||
complexity: UInt32 = 10, dred: Bool = false) {
|
||||
self.codec = codec; self.stereo = stereo; self.sampleRate = sampleRate
|
||||
self.bitrateBps = bitrateBps; self.frameMs = frameMs; self.application = application
|
||||
self.fec = fec; self.expectedPacketLoss = expectedPacketLoss; self.dtx = dtx
|
||||
self.complexity = complexity
|
||||
self.complexity = complexity; self.dred = dred
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,9 +220,14 @@ public struct StreamDescriptor: Sendable, Equatable {
|
||||
/// nil = default device for this kind.
|
||||
public let deviceId: String?
|
||||
public let label: String
|
||||
/// When true the caller feeds PCM via `feedPcm` (e.g. the iOS VPIO mic path) and the core
|
||||
/// skips opening a hardware capture device for this stream. Mirrors `vc_stream_desc.external_feed`.
|
||||
public let externalFeed: Bool
|
||||
|
||||
public init(kind: VoiceCatStreamKind, deviceId: String? = nil, label: String) {
|
||||
public init(kind: VoiceCatStreamKind, deviceId: String? = nil, label: String,
|
||||
externalFeed: Bool = false) {
|
||||
self.kind = kind; self.deviceId = deviceId; self.label = label
|
||||
self.externalFeed = externalFeed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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,36 +1,6 @@
|
||||
// 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.)
|
||||
// Swift binding invariants: native config strings outlive the handle, destroy joins callback
|
||||
// threads before deallocation, and callback payloads are copied before main-queue delivery.
|
||||
// See docs/architecture.md §4 for the complete binding contract.
|
||||
|
||||
import VoiceCatC
|
||||
import Foundation
|
||||
@@ -41,6 +11,10 @@ import Foundation
|
||||
/// `VcPcmSinkCallback` delegate.
|
||||
public typealias VoiceCatPcmSinkCallback = vc_pcm_sink_cb
|
||||
|
||||
/// Swift-idiomatic alias for the C `vc_mixed_output_cb` function-pointer type from `voicecat.h`
|
||||
/// — the external mixed-output sink used by the iOS VPIO path (see `setMixedOutputSink`).
|
||||
public typealias VoiceCatMixedOutputCallback = vc_mixed_output_cb
|
||||
|
||||
/// The Swift wrapper around `vc_client*`. Owns the native handle for its entire lifetime;
|
||||
/// `deinit` destroys it. Events and level meters are delivered on the main queue via the
|
||||
/// `onEvent` / `onLevel` closures.
|
||||
@@ -52,26 +26,14 @@ 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.
|
||||
/// Unretained callback context; destroying the handle joins callback threads first.
|
||||
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`.
|
||||
/// The core retains these pointers for the handle's lifetime.
|
||||
private var clientNamePtr: UnsafeMutablePointer<CChar>?
|
||||
private var clientVersionPtr: UnsafeMutablePointer<CChar>?
|
||||
private var tofuStorePathPtr: UnsafeMutablePointer<CChar>?
|
||||
@@ -86,7 +48,6 @@ public final class VoiceCatClient {
|
||||
/// 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] = [:]
|
||||
@@ -94,19 +55,12 @@ public final class VoiceCatClient {
|
||||
|
||||
// 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)
|
||||
@@ -215,7 +169,7 @@ public final class VoiceCatClient {
|
||||
VoiceCatResult(vc_authenticate_user(handle, username, password))
|
||||
}
|
||||
|
||||
// MARK: - TOFU server-identity gate (M4)
|
||||
// MARK: - TOFU server-identity gate
|
||||
|
||||
/// Accept or reject the pending server-identity check. Call after a `.serverIdentity`
|
||||
/// event. `accept=true` on firstConnect/mismatch updates the pin file and proceeds;
|
||||
@@ -252,6 +206,16 @@ public final class VoiceCatClient {
|
||||
VoiceCatResult(vc_leave_channel(handle))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func joinVoice() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_join_voice(handle))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func leaveVoice() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_leave_voice(handle))
|
||||
}
|
||||
|
||||
/// Pull the current channel tree. Re-call after `.channelList`/`.userJoined`/`.userLeft`/
|
||||
/// `.userUpdated` events. The native list is freed inside this call — callers never
|
||||
/// manage native lifetime.
|
||||
@@ -292,6 +256,7 @@ public final class VoiceCatClient {
|
||||
desc.kind = descriptor.kind.cValue
|
||||
desc.device_id = deviceIdPtr.map { UnsafePointer($0) }
|
||||
desc.label = UnsafePointer(labelPtr)
|
||||
desc.external_feed = descriptor.externalFeed ? 1 : 0
|
||||
|
||||
let r = vc_stream_start(handle, &desc, &streamId)
|
||||
return (VoiceCatResult(r), streamId)
|
||||
@@ -355,6 +320,26 @@ public final class VoiceCatClient {
|
||||
VoiceCatResult(vc_set_pcm_sink(handle, cb, user))
|
||||
}
|
||||
|
||||
/// External mixed-output sink (iOS VPIO) — receives the FINAL mixed remote audio as int16
|
||||
/// PCM on the core's mixer-timer thread when external playback is enabled. The Swift VPIO
|
||||
/// renderer copies this into its ring and plays it through the voice-processing output so
|
||||
/// echo cancellation has its reference signal. Pass `nil` to disable. Mirrors
|
||||
/// `vc_set_mixed_output_sink`. The callback MUST NOT block or allocate.
|
||||
@discardableResult
|
||||
public func setMixedOutputSink(_ cb: VoiceCatMixedOutputCallback?,
|
||||
user: UnsafeMutableRawPointer?) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_mixed_output_sink(handle, cb, user))
|
||||
}
|
||||
|
||||
/// Enable/disable external-playback mode (iOS VPIO). When enabled, the core opens NO hardware
|
||||
/// playback device; it drives decode+mix on a timer and delivers the final mix via
|
||||
/// `setMixedOutputSink`. Apply before the engine starts, or follow with `audioRestart()` to
|
||||
/// apply to a running engine. Mirrors `vc_set_external_playback`.
|
||||
@discardableResult
|
||||
public func setExternalPlayback(_ enabled: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_external_playback(handle, enabled ? 1 : 0))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setInputMode(_ mode: VoiceCatInputMode) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_input_mode(handle, mode.cValue))
|
||||
@@ -378,12 +363,29 @@ public final class VoiceCatClient {
|
||||
|
||||
/// 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.
|
||||
/// Windows client's `SetOutputVolume` and the C ABI `vc_set_output_volume`.
|
||||
@discardableResult
|
||||
public func setOutputVolume(_ gain: Float) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain))
|
||||
}
|
||||
|
||||
/// Send-side microphone input gain. Applied to captured MIC PCM before the VAD/PTT gate and
|
||||
/// Opus encode (so boosting a quiet mic also helps it cross the VAD threshold). gain 0.0 =
|
||||
/// silent, 1.0 = unity (default), >1.0 amplifies (clamped to int16). Always LOCAL.
|
||||
@discardableResult
|
||||
public func setInputGain(_ gain: Float) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_input_gain(handle, gain < 0 ? 0 : gain))
|
||||
}
|
||||
|
||||
/// Send-side microphone noise suppression (RNNoise). Denoises captured MIC PCM before the
|
||||
/// input gain and VAD/PTT gate, so everyone hears the cleaned signal (one pass for all
|
||||
/// listeners). MIC stream only, mono only; always LOCAL — no protocol traffic. Independent
|
||||
/// of the per-listener receive-side NR in `setRemoteStream` (docs/voice.md §10).
|
||||
@discardableResult
|
||||
public func setInputNoiseReduction(_ enable: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_input_noise_reduction(handle, enable ? 1 : 0))
|
||||
}
|
||||
|
||||
// MARK: - AVAudioSession interruption hooks (iOS)
|
||||
|
||||
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
|
||||
@@ -448,7 +450,7 @@ public final class VoiceCatClient {
|
||||
return Marshaling.devices(&native)
|
||||
}
|
||||
|
||||
// MARK: - M5: Moderation
|
||||
// MARK: - Moderation
|
||||
|
||||
@discardableResult
|
||||
public func kickUser(_ userId: UInt32, reason: String? = nil) -> VoiceCatResult {
|
||||
@@ -483,7 +485,7 @@ public final class VoiceCatClient {
|
||||
VoiceCatResult(vc_move_user(handle, userId, channelId))
|
||||
}
|
||||
|
||||
// MARK: - M5: Channel admin
|
||||
// MARK: - Channel admin
|
||||
|
||||
@discardableResult
|
||||
public func createChannel(_ info: ChannelEdit) -> VoiceCatResult {
|
||||
@@ -506,7 +508,7 @@ public final class VoiceCatClient {
|
||||
VoiceCatResult(vc_delete_channel(handle, channelId))
|
||||
}
|
||||
|
||||
// MARK: - M5: Account admin
|
||||
// MARK: - Account admin
|
||||
|
||||
@discardableResult
|
||||
public func createAccount(_ username: String, password: String) -> VoiceCatResult {
|
||||
@@ -586,6 +588,7 @@ extension AudioConfig {
|
||||
n.expected_packet_loss = expectedPacketLoss
|
||||
n.dtx = dtx ? 1 : 0
|
||||
n.complexity = complexity
|
||||
n.dred = dred ? 1 : 0
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ private final class ServerHarness {
|
||||
}
|
||||
self.port = port
|
||||
|
||||
// Provision a known admin account for moderation/admin tests (M5).
|
||||
// Provision a known admin account for moderation/admin tests.
|
||||
let adminURL = URL(fileURLWithPath: repoRoot)
|
||||
.appendingPathComponent("build/dev/bin/voicecat-admin")
|
||||
guard FileManager.default.isExecutableFile(atPath: adminURL.path) else {
|
||||
@@ -252,12 +252,12 @@ final class VoiceCatClientSmokeTests: XCTestCase {
|
||||
XCTAssertTrue(channels.contains { $0.id == 1 && $0.name == "Lobby" },
|
||||
"expected Lobby (channel 1) in \(channels.map { $0.name })")
|
||||
|
||||
// M5: permissions getter round-trip.
|
||||
// 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
|
||||
// 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)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.cat.voice.VoiceCat</string>
|
||||
<string>group.me.iamtalon.voicecat</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
AAAA00000000000000000002 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000001 /* Assets.xcassets */; };
|
||||
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000017 /* VoiceCatiOSApp.swift */; };
|
||||
BBBB00000000000000000031 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000018 /* AppState.swift */; };
|
||||
BBBB00000000000000000032 /* SessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000019 /* SessionState.swift */; };
|
||||
@@ -33,6 +34,7 @@
|
||||
BBBB00000000000000000046 /* AccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002D /* AccountsView.swift */; };
|
||||
BBBB00000000000000000047 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002E /* SettingsView.swift */; };
|
||||
BBBB0000000000000000004B /* IOSAudioRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002F /* IOSAudioRouter.swift */; };
|
||||
BBBB00000000000000000F02 /* IOSVoiceProcessingEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000F01 /* IOSVoiceProcessingEngine.swift */; };
|
||||
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */ = {isa = PBXBuildFile; productRef = BBBB0000000000000000004A /* VoiceCatCore */; };
|
||||
CCCC00000000000000000010 /* BroadcastAudioPump.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000002 /* BroadcastAudioPump.swift */; };
|
||||
CCCC00000000000000000011 /* BroadcastAudioRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000001 /* BroadcastAudioRing.swift */; };
|
||||
@@ -60,6 +62,7 @@
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
AAAA00000000000000000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000012 /* VoiceCatiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceCatiOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BBBB00000000000000000015 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatiOS.entitlements; sourceTree = "<group>"; };
|
||||
@@ -89,6 +92,7 @@
|
||||
BBBB0000000000000000002D /* AccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002F /* IOSAudioRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSAudioRouter.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000F01 /* IOSVoiceProcessingEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSVoiceProcessingEngine.swift; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000001 /* BroadcastAudioRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastAudioRing.swift; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000002 /* BroadcastAudioPump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastAudioPump.swift; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000003 /* SampleHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleHandler.swift; sourceTree = "<group>"; };
|
||||
@@ -136,6 +140,7 @@
|
||||
BBBB00000000000000000003 /* VoiceCatiOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AAAA00000000000000000001 /* Assets.xcassets */,
|
||||
BBBB00000000000000000015 /* Info.plist */,
|
||||
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */,
|
||||
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */,
|
||||
@@ -143,6 +148,7 @@
|
||||
BBBB00000000000000000019 /* SessionState.swift */,
|
||||
BBBB0000000000000000001A /* AudioSessionManager.swift */,
|
||||
BBBB0000000000000000002F /* IOSAudioRouter.swift */,
|
||||
BBBB00000000000000000F01 /* IOSVoiceProcessingEngine.swift */,
|
||||
BBBB0000000000000000001B /* ServerListStore.swift */,
|
||||
BBBB0000000000000000001C /* SavedServer.swift */,
|
||||
CCCC00000000000000000002 /* BroadcastAudioPump.swift */,
|
||||
@@ -281,6 +287,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AAAA00000000000000000002 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -296,6 +303,7 @@
|
||||
BBBB00000000000000000032 /* SessionState.swift in Sources */,
|
||||
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */,
|
||||
BBBB0000000000000000004B /* IOSAudioRouter.swift in Sources */,
|
||||
BBBB00000000000000000F02 /* IOSVoiceProcessingEngine.swift in Sources */,
|
||||
BBBB00000000000000000034 /* ServerListStore.swift in Sources */,
|
||||
BBBB00000000000000000035 /* SavedServer.swift in Sources */,
|
||||
CCCC00000000000000000010 /* BroadcastAudioPump.swift in Sources */,
|
||||
@@ -469,7 +477,7 @@
|
||||
"$(inherited)",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.9;
|
||||
@@ -498,7 +506,7 @@
|
||||
"$(inherited)",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.9;
|
||||
@@ -522,7 +530,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS.broadcast;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat.broadcast;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -547,7 +555,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS.broadcast;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat.broadcast;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import VoiceCatCore
|
||||
|
||||
struct PendingIdentity: Identifiable {
|
||||
@@ -25,6 +26,33 @@ final class AppState {
|
||||
private(set) var connectingServer: SavedServer?
|
||||
private var identityHandled = false
|
||||
|
||||
/// Retained after authentication so an interrupted session can be restored.
|
||||
private var connectedServer: SavedServer?
|
||||
|
||||
// MARK: - Reconnect state
|
||||
|
||||
/// Distinguishes an explicit disconnect from a transport failure.
|
||||
private var userInitiatedDisconnect = false
|
||||
|
||||
private struct LastSession {
|
||||
let server: SavedServer
|
||||
let channelId: UInt32
|
||||
let voiceSubscribed: Bool
|
||||
let micMuted: Bool
|
||||
let deafened: Bool
|
||||
}
|
||||
private var lastSession: LastSession?
|
||||
|
||||
private var reconnectAttempt = 0
|
||||
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
|
||||
/// Detects interface changes before TCP keepalive notices a dead path.
|
||||
private var pathMonitor: NWPathMonitor?
|
||||
private let pathQueue = DispatchQueue(label: "cat.voice.network.path")
|
||||
|
||||
private var lastPathSignature: String?
|
||||
|
||||
// MARK: - Server list management
|
||||
|
||||
func addServer(_ server: SavedServer, password: String?) {
|
||||
@@ -54,11 +82,19 @@ final class AppState {
|
||||
// MARK: - Connect flow
|
||||
|
||||
func connectTo(_ server: SavedServer) {
|
||||
connectTo(server, restoring: nil)
|
||||
}
|
||||
|
||||
private func connectTo(_ server: SavedServer, restoring: LastSession?) {
|
||||
guard !isConnecting else { return }
|
||||
isConnecting = true
|
||||
connectStatus = "Connecting…"
|
||||
connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
|
||||
connectingServer = server
|
||||
identityHandled = false
|
||||
userInitiatedDisconnect = false
|
||||
|
||||
// Releasing the wrapper joins the core's I/O thread before freeing native strings.
|
||||
connectingClient = nil
|
||||
|
||||
let config = VoiceCatConfig(
|
||||
clientName: "VoiceCat-iOS",
|
||||
@@ -69,14 +105,17 @@ final class AppState {
|
||||
connectingClient = client
|
||||
|
||||
client.onEvent = { [weak self] ev in
|
||||
Task { @MainActor [weak self] in self?.handleConnectEvent(ev, server: server) }
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleConnectEvent(ev, server: server, restoring: restoring)
|
||||
}
|
||||
}
|
||||
// Authentication can start audio, so select the external path before connecting.
|
||||
client.setExternalPlayback(true)
|
||||
client.connect(host: server.host, port: server.port)
|
||||
|
||||
// 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
|
||||
let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
|
||||
client.authenticateGuest(nick)
|
||||
case .password:
|
||||
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)
|
||||
@@ -89,13 +128,19 @@ final class AppState {
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
session?.stopMicStream()
|
||||
// Set before disconnect so its event cannot arm reconnect.
|
||||
userInitiatedDisconnect = true
|
||||
cancelReconnect()
|
||||
lastSession = nil
|
||||
session?.leaveVoice()
|
||||
session?.client.disconnect()
|
||||
IOSAudioEngine.shared.stop()
|
||||
AudioSessionManager.shared.deactivateSession()
|
||||
session = nil
|
||||
connectingClient?.disconnect()
|
||||
connectingClient = nil
|
||||
connectingServer = nil
|
||||
connectedServer = nil
|
||||
isConnecting = false
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
@@ -116,22 +161,139 @@ final class AppState {
|
||||
}
|
||||
|
||||
func cancelConnect() {
|
||||
// User explicitly cancelled — no reconnect for the resulting .disconnected event.
|
||||
userInitiatedDisconnect = true
|
||||
cancelReconnect()
|
||||
lastSession = nil
|
||||
connectingClient?.disconnect()
|
||||
connectingClient = nil
|
||||
connectingServer = nil
|
||||
connectedServer = nil
|
||||
isConnecting = false
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
pendingIdentity = nil
|
||||
}
|
||||
|
||||
// MARK: - Reconnect orchestration
|
||||
|
||||
private func cancelReconnect() {
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
stopPathMonitor()
|
||||
}
|
||||
|
||||
/// Schedules the next reconnect with exponential backoff capped at 30 seconds.
|
||||
private func scheduleReconnect() {
|
||||
guard !userInitiatedDisconnect, let last = lastSession else { return }
|
||||
reconnectTask?.cancel()
|
||||
reconnectAttempt = max(1, reconnectAttempt + 1)
|
||||
let delaySec = min(pow(2.0, Double(reconnectAttempt - 1)), 30.0)
|
||||
connectStatus = "Reconnecting (attempt \(reconnectAttempt))…"
|
||||
|
||||
startPathMonitor()
|
||||
|
||||
let task = Task { [weak self, last] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(delaySec * 1_000_000_000))
|
||||
if Task.isCancelled { return }
|
||||
guard !self.userInitiatedDisconnect else { return }
|
||||
guard self.lastSession != nil else { return }
|
||||
guard self.session == nil else { return }
|
||||
self.connectTo(last.server, restoring: last)
|
||||
}
|
||||
reconnectTask = task
|
||||
}
|
||||
|
||||
private func startPathMonitor() {
|
||||
guard pathMonitor == nil else { return }
|
||||
let monitor = NWPathMonitor()
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
guard !self.userInitiatedDisconnect else { return }
|
||||
let sig = Self.pathSignature(path)
|
||||
let prevSig = self.lastPathSignature
|
||||
self.lastPathSignature = sig
|
||||
if prevSig == nil { return }
|
||||
|
||||
if self.session != nil {
|
||||
if path.status != .satisfied || sig != prevSig {
|
||||
self.proactiveReconnect()
|
||||
}
|
||||
} else if self.lastSession != nil {
|
||||
if path.status == .satisfied {
|
||||
self.reconnectAttempt = 0
|
||||
self.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: pathQueue)
|
||||
pathMonitor = monitor
|
||||
}
|
||||
|
||||
private func stopPathMonitor() {
|
||||
pathMonitor?.cancel()
|
||||
pathMonitor = nil
|
||||
lastPathSignature = nil
|
||||
}
|
||||
|
||||
private static func pathSignature(_ path: NWPath) -> String {
|
||||
guard path.status == .satisfied else { return "unsatisfied" }
|
||||
var parts: [String] = []
|
||||
if path.usesInterfaceType(.wifi) { parts.append("wifi") }
|
||||
if path.usesInterfaceType(.cellular) { parts.append("cellular") }
|
||||
if path.usesInterfaceType(.wiredEthernet) { parts.append("wired") }
|
||||
if path.usesInterfaceType(.other) { parts.append("other") }
|
||||
return parts.isEmpty ? "none" : parts.sorted().joined(separator: "+")
|
||||
}
|
||||
|
||||
// MARK: - Live-session disconnect (called by SessionState)
|
||||
|
||||
/// Receives disconnects after `SessionState` takes ownership of authenticated events.
|
||||
func onLiveSessionDisconnected() {
|
||||
guard !userInitiatedDisconnect else { return }
|
||||
teardownLiveSessionAndReconnect(sound: false)
|
||||
}
|
||||
|
||||
private func proactiveReconnect() {
|
||||
guard !userInitiatedDisconnect else { return }
|
||||
guard session != nil else { return }
|
||||
teardownLiveSessionAndReconnect(sound: true)
|
||||
}
|
||||
|
||||
private func teardownLiveSessionAndReconnect(sound: Bool) {
|
||||
if let s = session, let srv = connectedServer {
|
||||
lastSession = LastSession(
|
||||
server: srv,
|
||||
channelId: s.currentChannelId,
|
||||
voiceSubscribed: s.voiceState.voiceSubscribed,
|
||||
micMuted: s.voiceState.selfMuted,
|
||||
deafened: s.voiceState.selfDeafened)
|
||||
}
|
||||
IOSAudioEngine.shared.stop()
|
||||
AudioSessionManager.shared.deactivateSession()
|
||||
session = nil
|
||||
isConnecting = false
|
||||
connectingClient = nil
|
||||
connectedServer = nil
|
||||
if sound {
|
||||
EventFeedback.shared.play(.connectionLost)
|
||||
EventFeedback.shared.speak("Network changed — reconnecting")
|
||||
}
|
||||
reconnectAttempt = 0
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
// MARK: - Connect event handler
|
||||
|
||||
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer) {
|
||||
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer,
|
||||
restoring: LastSession?) {
|
||||
switch ev.type {
|
||||
case .connectionState:
|
||||
switch ev.connectionState {
|
||||
case .connecting: connectStatus = "Connecting…"
|
||||
case .connecting: connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
|
||||
case .tlsHandshake: connectStatus = "TLS handshake…"
|
||||
case .authenticating: connectStatus = "Authenticating…"
|
||||
case .verifyingIdentity: connectStatus = "Verifying server identity…"
|
||||
@@ -153,35 +315,86 @@ final class AppState {
|
||||
guard let client = connectingClient else { break }
|
||||
let perms = client.getPermissions()
|
||||
let newSession = SessionState(client: client, selfUserId: ev.userId, permissions: perms)
|
||||
newSession.appState = self
|
||||
connectingClient = nil
|
||||
isConnecting = false
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
connectedServer = server
|
||||
self.session = newSession
|
||||
// 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.
|
||||
EventFeedback.shared.play(.login)
|
||||
EventFeedback.shared.speak(restoring != nil ? "Reconnected" : "Connected")
|
||||
// External-playback mode was enabled before connect() so the core never opens a
|
||||
// miniaudio device on iOS (the single ordering rule of the unified audio path).
|
||||
// Now activate the session and start the engine in listening mode so remote audio
|
||||
// plays the moment someone talks, even before we join voice (no "can't hear anyone").
|
||||
do {
|
||||
try AudioSessionManager.shared.ensureSessionActive()
|
||||
} catch {
|
||||
print("Audio session activate on connect failed: \(error)")
|
||||
}
|
||||
IOSAudioEngine.shared.startListening(client: client)
|
||||
// The path monitor runs the whole time we're connected so a network change fires
|
||||
// proactiveReconnect immediately instead of waiting for the C core's TCP keepalive
|
||||
// timeout (~30-60 s on a hard Wi-Fi drop). It stays armed across reconnects and is
|
||||
// stopped only on user-initiated disconnect.
|
||||
startPathMonitor()
|
||||
|
||||
// Reconnect restore: rejoin the prior channel and re-enable voice/mic if they
|
||||
// were on. The session is fresh (server auto-places us in Lobby), so the restore
|
||||
// is driven through SessionState.requestRestore, which issues a JoinChannel then
|
||||
// (on the resulting .joinResult) re-arms voice + mute/deafen. A successful auth
|
||||
// means the server is reachable, so the backoff counter resets and `lastSession`
|
||||
// clears; the path monitor keeps watching for the next change.
|
||||
if let restoring {
|
||||
newSession.requestRestore(channelId: restoring.channelId,
|
||||
voiceSubscribed: restoring.voiceSubscribed,
|
||||
micMuted: restoring.micMuted,
|
||||
deafened: restoring.deafened)
|
||||
reconnectAttempt = 0
|
||||
lastSession = nil
|
||||
}
|
||||
} else {
|
||||
connectStatus = "Auth failed: \(ev.result.description)"
|
||||
showPasswordPrompt = true
|
||||
}
|
||||
case .disconnected:
|
||||
if session == nil { cancelConnect() }
|
||||
else {
|
||||
// This handler runs ONLY during the connecting phase — after auth success
|
||||
// `SessionState.init` overwrites `client.onEvent`, so a live-session disconnect
|
||||
// reaches `SessionState.handleEvent` and comes back via
|
||||
// `onLiveSessionDisconnected`, not here. Two outcomes for this branch:
|
||||
// - A reconnect's connecting phase failed (`lastSession != nil`, set by a prior
|
||||
// teardown) → re-arm `scheduleReconnect` so the backoff loop continues.
|
||||
// - A fresh connect failed before auth (`lastSession == nil`) → show the error, do
|
||||
// not auto-reconnect (the user should retry manually once the server is reachable).
|
||||
connectingClient = nil
|
||||
isConnecting = false
|
||||
IOSAudioEngine.shared.stop()
|
||||
AudioSessionManager.shared.deactivateSession()
|
||||
session = nil; isConnecting = false
|
||||
|
||||
if userInitiatedDisconnect {
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
pendingIdentity = nil
|
||||
lastSession = nil
|
||||
connectedServer = nil
|
||||
cancelReconnect()
|
||||
} else if lastSession != nil {
|
||||
// Mid-reconnect drop — keep the backoff loop going.
|
||||
EventFeedback.shared.play(.connectionLost)
|
||||
EventFeedback.shared.speak("Connection lost — reconnecting")
|
||||
scheduleReconnect()
|
||||
} else {
|
||||
// Fresh connect failed before auth. Surface the reason; no auto-reconnect.
|
||||
connectStatus = ev.text ?? "Disconnected"
|
||||
showPasswordPrompt = false
|
||||
pendingIdentity = nil
|
||||
connectedServer = nil
|
||||
cancelReconnect()
|
||||
}
|
||||
case .error:
|
||||
connectStatus = ev.text ?? "Unknown error"
|
||||
if session == nil { isConnecting = false }
|
||||
// Errors don't disconnect us; the .disconnected event handles teardown/reconnect.
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,6 @@ private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "Audio
|
||||
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
|
||||
@@ -22,6 +15,10 @@ final class AudioSessionManager {
|
||||
/// want to hear remote audio.
|
||||
private var isSessionActive = false
|
||||
|
||||
/// Whether the AVAudioSession is currently active (we activated it). Read by `IOSAudioRouter`
|
||||
/// to decide whether the post-activation A2DP speaker fallback can be applied.
|
||||
var isActive: Bool { isSessionActive }
|
||||
|
||||
func configure() {
|
||||
// Load stored audio routing preferences and apply them before any audio session
|
||||
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
|
||||
@@ -38,6 +35,20 @@ final class AudioSessionManager {
|
||||
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
/// Idempotently restores audio after an interruption or external route change.
|
||||
func recoverAudio() {
|
||||
guard IOSAudioEngine.shared.isConnected else { return }
|
||||
do {
|
||||
try ensureSessionActive()
|
||||
} catch {
|
||||
logger.error("recoverAudio — session activate failed: \(error.localizedDescription)")
|
||||
}
|
||||
IOSAudioRouter.shared.applyConfiguration()
|
||||
if isSessionActive { IOSAudioRouter.shared.applyA2dpSpeakerFallback() }
|
||||
IOSAudioEngine.shared.reconfigure()
|
||||
logSessionState("after recoverAudio")
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -50,12 +61,10 @@ final class AudioSessionManager {
|
||||
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)
|
||||
}
|
||||
// For the A2DP output presets, pick the right output once the session is live: defer to
|
||||
// a connected A2DP/wired/AirPlay route, but fall back to the loud built-in speaker (not
|
||||
// the quiet earpiece) when nothing external is connected. See applyA2dpSpeakerFallback().
|
||||
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
|
||||
let route = AVAudioSession.sharedInstance().currentRoute
|
||||
let outputNames = route.outputs.map { $0.portName }.joined(separator: ", ")
|
||||
let inputNames = route.inputs.map { $0.portName }.joined(separator: ", ")
|
||||
@@ -110,22 +119,18 @@ final class AudioSessionManager {
|
||||
|
||||
switch type {
|
||||
case .began:
|
||||
// The system stops our AVAudioEngine and deactivates the session. Nothing to tear
|
||||
// down — `IOSAudioEngine` rebuilds on resume.
|
||||
logger.info("interruption began — session suspended by system")
|
||||
isSessionActive = false // system deactivated us
|
||||
client?.audioSuspend()
|
||||
isSessionActive = false
|
||||
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)")
|
||||
}
|
||||
}
|
||||
// Always attempt recovery when we have a live session. iOS sometimes ends an
|
||||
// interruption without the `.shouldResume` hint (e.g. Siri), and the previous
|
||||
// behavior of only reactivating when `.shouldResume` was set left the session
|
||||
// permanently dead — audio never came back. `recoverAudio()` is intent-gated on
|
||||
// `IOSAudioEngine.isConnected` and idempotent, so speculatively calling it is safe.
|
||||
logger.info("interruption ended — recovery requested")
|
||||
recoverAudio()
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
@@ -135,29 +140,23 @@ final class AudioSessionManager {
|
||||
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
||||
else {
|
||||
logger.warning("routeChange — unknown reason, refreshing only")
|
||||
logger.warning("routeChange — unknown reason, refreshing + recovery")
|
||||
IOSAudioRouter.shared.refreshRoutes()
|
||||
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||
recoverAudio()
|
||||
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)
|
||||
|
||||
// Ignore notifications caused by our own configuration calls; rebuilding for them
|
||||
// recursively emits more route changes. Engine-configuration notifications remain
|
||||
// the recovery path if a self-initiated change actually stops AVAudioEngine.
|
||||
if reason != .categoryChange && reason != .routeConfigurationChange && reason != .override {
|
||||
recoverAudio()
|
||||
}
|
||||
logSessionState("route change (\(reasonLabel(reason)))")
|
||||
}
|
||||
|
||||
@@ -170,6 +169,7 @@ final class AudioSessionManager {
|
||||
case .wakeFromSleep: return "wakeFromSleep"
|
||||
case .noSuitableRouteForCategory: return "noSuitableRouteForCategory"
|
||||
case .routeConfigurationChange: return "routeConfigurationChange"
|
||||
case .unknown: return "unknown"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,47 +4,8 @@ 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.
|
||||
/// Owns `AVAudioSession` routing for the iOS external-audio path.
|
||||
/// Route configuration and ordering constraints are documented in `docs/voice.md`.
|
||||
@MainActor
|
||||
final class IOSAudioRouter: ObservableObject {
|
||||
|
||||
@@ -55,82 +16,68 @@ final class IOSAudioRouter: ObservableObject {
|
||||
@Published var inputPorts: [IOSAudioInputPort] = []
|
||||
@Published var outputRoutes: [IOSAudioOutputRoute] = []
|
||||
@Published var bluetoothMode: BluetoothMode = .btHfpVoice
|
||||
/// User-requested speaker fallback: when on, route to the built-in speaker instead of the
|
||||
/// earpiece (receiver) when no headphones/Bluetooth are connected. Orthogonal to the
|
||||
/// bluetooth mode and presets. Default off — current behavior is unchanged for existing users.
|
||||
@Published var forceSpeaker: Bool = false
|
||||
@Published var micMode: MicMode = .standard
|
||||
@Published var captureChannels: CaptureChannels = .mono
|
||||
@Published var selectedInputPortId: String?
|
||||
@Published var selectedDataSourceId: String?
|
||||
@Published var selectedPolarPattern: String?
|
||||
/// Master voice-processing switch (Apple VPIO: AEC + noise suppression bundled together).
|
||||
/// iOS exposes no per-stage toggle, so this is the finest "echo cancellation / noise
|
||||
/// reduction" control available. Only takes effect on a VPIO-capable config (mono + standard
|
||||
/// + not A2DP); stereo / A2DP configs can't use VPIO regardless. Default on.
|
||||
@Published var voiceProcessingEnabled: Bool = true
|
||||
/// VPIO automatic gain control — the one VPIO sub-stage iOS lets us toggle independently.
|
||||
/// Only meaningful when voice processing is active. Default on.
|
||||
@Published var agcEnabled: Bool = true
|
||||
@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".
|
||||
/// Audio presets — the four scenarios from the product spec. Pick a preset for a quick start,
|
||||
/// then fine-tune individual settings under "Advanced". HFP / wired headsets are not separate
|
||||
/// presets: Voice Chat lets the system route to them, and Advanced exposes manual selection.
|
||||
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.
|
||||
/// Voice chat: Apple VPIO does real AEC + noise suppression + AGC. Mono. The system picks
|
||||
/// the best route (Bluetooth HFP / wired / speaker / earpiece). 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.
|
||||
/// Internal **stereo** built-in mic regardless of the output route. A2DP output when a
|
||||
/// Bluetooth headset is connected, else built-in speaker / wired. No VPIO (stereo can't
|
||||
/// use it). 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"
|
||||
/// Internal **mono** built-in mic regardless of the output route. A2DP output when a
|
||||
/// Bluetooth headset is connected, else built-in speaker / wired. No VPIO. Always available.
|
||||
case monoMic = "Mono Mic"
|
||||
/// Everything manual — input port, mic orientation / polar pattern, mono/stereo, Bluetooth
|
||||
/// mode, raw vs standard, and the VPIO / AGC toggles. Also the display state when the
|
||||
/// individual settings don't match a named preset.
|
||||
case advanced = "Advanced"
|
||||
|
||||
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
|
||||
case .voiceChat: return .btHfpVoice
|
||||
// Internal-mic presets: A2DP output when BT is connected; speaker/wired when not.
|
||||
case .stereoMic, .monoMic: return .builtInMicBtA2dp
|
||||
case .advanced: return .builtInMicSpeaker // placeholder; Advanced sets it manually
|
||||
}
|
||||
}
|
||||
|
||||
var captureChannels: CaptureChannels {
|
||||
switch self {
|
||||
case .stereoMic, .studio: return .stereo
|
||||
default: return .mono
|
||||
}
|
||||
self == .stereoMic ? .stereo : .mono
|
||||
}
|
||||
|
||||
var micMode: MicMode {
|
||||
switch self {
|
||||
case .studio: return .raw
|
||||
default: return .standard
|
||||
}
|
||||
}
|
||||
var micMode: MicMode { .standard }
|
||||
|
||||
/// Whether this preset explicitly selects the built-in mic port.
|
||||
/// Whether this preset explicitly pins the built-in mic port (the internal-mic presets).
|
||||
var usesBuiltInMic: Bool {
|
||||
switch self {
|
||||
case .stereoMic, .studio, .btHeadphonesMonoMic: return true
|
||||
case .stereoMic, .monoMic: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
@@ -165,14 +112,16 @@ final class IOSAudioRouter: ObservableObject {
|
||||
private let kDataSourceId = "cat.voice.audio.dataSourceId"
|
||||
private let kPolarPattern = "cat.voice.audio.polarPattern"
|
||||
private let kPreset = "cat.voice.audio.preset"
|
||||
private let kForceSpeaker = "cat.voice.audio.forceSpeaker"
|
||||
private let kVoiceProcessing = "cat.voice.audio.voiceProcessing"
|
||||
private let kAgc = "cat.voice.audio.agc"
|
||||
|
||||
/// 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).
|
||||
/// AVAudioSession setters can synchronously emit route-change notifications.
|
||||
private var isApplyingConfiguration = false
|
||||
|
||||
/// Prevents redundant overrides; `setCategory` invalidates the cached value.
|
||||
private var lastAppliedOutputOverride: AVAudioSession.PortOverride?
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Load / refresh from AVAudioSession
|
||||
@@ -266,47 +215,41 @@ final class IOSAudioRouter: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
/// The presets the user can pick. All four are always available — the named presets simply
|
||||
/// describe what to do "regardless of the output route", and Advanced is always offered.
|
||||
var availablePresets: [AudioPreset] { AudioPreset.allCases }
|
||||
|
||||
/// 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).
|
||||
/// Which named preset matches the current settings, or `.advanced` if nothing matches.
|
||||
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 {
|
||||
for preset in [AudioPreset.voiceChat, .stereoMic, .monoMic] {
|
||||
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
|
||||
return .advanced
|
||||
}
|
||||
|
||||
/// Whether the current configuration should engage Apple's Voice-Processing I/O unit (VPIO:
|
||||
/// real AEC + noise suppression + AGC, driven by `IOSAudioEngine`). VPIO forces mono and
|
||||
/// can't run on an A2DP route, so it is available only for a mono + standard + non-A2DP
|
||||
/// config, and then only when the user hasn't disabled it via the Advanced master toggle.
|
||||
var currentConfigUsesVoiceProcessing: Bool {
|
||||
voiceProcessingEnabled && voiceProcessingAvailable
|
||||
}
|
||||
|
||||
/// Whether the current config *could* use VPIO (mono + standard + non-A2DP), independent of
|
||||
/// the user's master toggle. Drives whether the Advanced "Voice Processing" switch is shown.
|
||||
var voiceProcessingAvailable: Bool {
|
||||
captureChannels == .mono && micMode == .standard && bluetoothMode != .builtInMicBtA2dp
|
||||
}
|
||||
|
||||
// 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
|
||||
/// Apply the full audio configuration to AVAudioSession. Call this before (re)building the
|
||||
/// `IOSAudioEngine` graph so the engine binds to the intended route (`applyAndReconfigure`
|
||||
/// does both). 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 {
|
||||
@@ -314,6 +257,9 @@ final class IOSAudioRouter: ObservableObject {
|
||||
return
|
||||
}
|
||||
isApplyingConfiguration = true
|
||||
// setCategory below can reset the override out from under us, so drop our cached
|
||||
// value — applyA2dpSpeakerFallback will re-derive and re-apply it from scratch.
|
||||
lastAppliedOutputOverride = nil
|
||||
defer { isApplyingConfiguration = false }
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
@@ -322,9 +268,11 @@ final class IOSAudioRouter: ObservableObject {
|
||||
// .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.
|
||||
// .defaultToSpeaker is set for the speaker preset and, when the user enables the
|
||||
// `forceSpeaker` toggle, for the HFP preset too — it forces output to the built-in
|
||||
// speaker instead of the receiver while still yielding to connected BT/wired output.
|
||||
// It also actively breaks A2DP routing in .playAndRecord, so it must NEVER be set for
|
||||
// the A2DP preset (forceSpeaker is intentionally ignored there).
|
||||
// .allowAirPlay is added to the Bluetooth presets so AirPlay output also works.
|
||||
var options: AVAudioSession.CategoryOptions = [.mixWithOthers]
|
||||
switch bluetoothMode {
|
||||
@@ -347,6 +295,14 @@ final class IOSAudioRouter: ObservableObject {
|
||||
options.insert(.defaultToSpeaker)
|
||||
}
|
||||
|
||||
// User-requested speaker fallback: route to the built-in speaker instead of the
|
||||
// receiver when no headphones/BT are connected. Skipped for the A2DP mode because
|
||||
// .defaultToSpeaker breaks A2DP routing (see note above). Redundant for
|
||||
// builtInMicSpeaker, which already sets it.
|
||||
if forceSpeaker && bluetoothMode != .builtInMicBtA2dp {
|
||||
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
|
||||
@@ -374,13 +330,8 @@ final class IOSAudioRouter: ObservableObject {
|
||||
|
||||
// 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.
|
||||
// See configureStereoCapture's doc comment for the full stereo-capture recipe
|
||||
// and why each step is necessary.
|
||||
configureStereoCapture(session: session)
|
||||
} else if let portId = selectedInputPortId, !portId.isEmpty,
|
||||
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
||||
@@ -401,16 +352,8 @@ final class IOSAudioRouter: ObservableObject {
|
||||
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.
|
||||
/// Anchors the built-in stereo data source without using
|
||||
/// `setPreferredInputNumberOfChannels`, which disrupts A2DP routing.
|
||||
private func configureStereoCapture(session: AVAudioSession) {
|
||||
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
|
||||
else {
|
||||
@@ -426,8 +369,6 @@ final class IOSAudioRouter: ObservableObject {
|
||||
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.
|
||||
@@ -514,6 +455,10 @@ final class IOSAudioRouter: ObservableObject {
|
||||
selectedInputPortId = UserDefaults.standard.string(forKey: kInputPortId)
|
||||
selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId)
|
||||
selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern)
|
||||
forceSpeaker = UserDefaults.standard.bool(forKey: kForceSpeaker)
|
||||
// VPIO toggles default ON when never set (object(forKey:) is nil → use true).
|
||||
voiceProcessingEnabled = (UserDefaults.standard.object(forKey: kVoiceProcessing) as? Bool) ?? true
|
||||
agcEnabled = (UserDefaults.standard.object(forKey: kAgc) as? Bool) ?? true
|
||||
}
|
||||
/// Persist current selections to UserDefaults.
|
||||
func savePreferences() {
|
||||
@@ -523,90 +468,106 @@ final class IOSAudioRouter: ObservableObject {
|
||||
UserDefaults.standard.set(selectedInputPortId, forKey: kInputPortId)
|
||||
UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId)
|
||||
UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern)
|
||||
UserDefaults.standard.set(forceSpeaker, forKey: kForceSpeaker)
|
||||
UserDefaults.standard.set(voiceProcessingEnabled, forKey: kVoiceProcessing)
|
||||
UserDefaults.standard.set(agcEnabled, forKey: kAgc)
|
||||
}
|
||||
|
||||
// MARK: - Selection setters (called from SettingsView pickers)
|
||||
|
||||
/// Persists the selection and rebuilds the engine against the resulting route.
|
||||
private func applyAndReconfigure() {
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
||||
refreshRoutes()
|
||||
IOSAudioEngine.shared.reconfigure()
|
||||
}
|
||||
|
||||
func selectInputPort(_ portId: String) {
|
||||
selectedInputPortId = portId
|
||||
selectedDataSourceId = nil
|
||||
selectedPolarPattern = nil
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
refreshRoutes()
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func selectDataSource(_ dataSourceId: String) {
|
||||
selectedDataSourceId = dataSourceId
|
||||
selectedPolarPattern = nil
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
refreshRoutes()
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func selectPolarPattern(_ pattern: String) {
|
||||
selectedPolarPattern = pattern
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
refreshRoutes()
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func selectBluetoothMode(_ mode: BluetoothMode) {
|
||||
bluetoothMode = mode
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
refreshRoutes()
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func setForceSpeaker(_ on: Bool) {
|
||||
forceSpeaker = on
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func selectMicMode(_ mode: MicMode) {
|
||||
micMode = mode
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func setVoiceProcessingEnabled(_ on: Bool) {
|
||||
voiceProcessingEnabled = on
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func setAgcEnabled(_ on: Bool) {
|
||||
agcEnabled = on
|
||||
// No session reconfigure needed — just rebuild the engine so VPIO picks up the AGC flag.
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
updateWarnings()
|
||||
IOSAudioEngine.shared.reconfigure()
|
||||
}
|
||||
|
||||
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()
|
||||
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
||||
refreshRoutes()
|
||||
// Push the channel count into the core's MIC stream, then rebuild the engine graph so the
|
||||
// mic tap captures the right number of channels. The engine owns the route now, so there's
|
||||
// no stereo-vs-A2DP race to sequence around.
|
||||
IOSAudioEngine.shared.setCaptureChannels(channels.channelCount)
|
||||
}
|
||||
|
||||
// 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.
|
||||
/// Apply a named preset — set all individual settings to the preset's values, then re-apply
|
||||
/// the configuration and rebind the engine. The internal-mic presets pin the built-in mic.
|
||||
func applyPreset(_ preset: AudioPreset) {
|
||||
guard preset != .custom else { return } // can't "apply" custom — it's a display state
|
||||
guard preset != .advanced else { return } // Advanced is a display state, not "applied"
|
||||
|
||||
bluetoothMode = preset.bluetoothMode
|
||||
micMode = preset.micMode
|
||||
captureChannels = preset.captureChannels
|
||||
|
||||
// Voice Chat is a phone-call experience — default to the loud speaker so output doesn't
|
||||
// land on the quiet earpiece (receiver). Still yields to connected BT/wired output.
|
||||
if preset == .voiceChat { forceSpeaker = true }
|
||||
|
||||
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: {
|
||||
// Pin the built-in mic. In stereo, iOS uses multiple capsules automatically; in mono
|
||||
// the default orientation is fine — so don't force a specific data source / pattern.
|
||||
if let builtInMic = (AVAudioSession.sharedInstance().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.
|
||||
// Voice Chat: let the system pick the input (Bluetooth HFP / wired / built-in).
|
||||
selectedInputPortId = nil
|
||||
selectedDataSourceId = nil
|
||||
selectedPolarPattern = nil
|
||||
@@ -615,15 +576,11 @@ final class IOSAudioRouter: ObservableObject {
|
||||
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()
|
||||
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
||||
refreshRoutes()
|
||||
// Push the channel count to the core, then rebuild the engine graph (VPIO on/off + tap).
|
||||
IOSAudioEngine.shared.setCaptureChannels(preset.captureChannels.channelCount)
|
||||
IOSAudioEngine.shared.reconfigure()
|
||||
logger.info("applyPreset — \(preset.rawValue)")
|
||||
}
|
||||
|
||||
@@ -642,9 +599,39 @@ final class IOSAudioRouter: ObservableObject {
|
||||
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 }
|
||||
/// Uses the speaker only when an A2DP-capable preset has no external output.
|
||||
/// The cached override avoids recursively generated route-change notifications.
|
||||
func applyA2dpSpeakerFallback() {
|
||||
guard bluetoothMode == .builtInMicBtA2dp else { return }
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
// Treat the built-in receiver and speaker as "internal"; anything else (A2DP, headphones,
|
||||
// USB, AirPlay) is an external output we should defer to.
|
||||
let hasExternalOutput = session.currentRoute.outputs.contains {
|
||||
$0.portType != .builtInReceiver && $0.portType != .builtInSpeaker
|
||||
}
|
||||
let desired: AVAudioSession.PortOverride = hasExternalOutput ? .none : .speaker
|
||||
if desired == lastAppliedOutputOverride {
|
||||
logger.debug("A2DP fallback — desired=\(self.overrideLabel(desired)) already applied, skipping")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try session.overrideOutputAudioPort(desired)
|
||||
lastAppliedOutputOverride = desired
|
||||
logger.info("A2DP mode — override applied: \(self.overrideLabel(desired))")
|
||||
} catch {
|
||||
// Drop the cache so the next call re-derives from the live session state.
|
||||
lastAppliedOutputOverride = nil
|
||||
logger.error("A2DP speaker fallback failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func overrideLabel(_ o: AVAudioSession.PortOverride) -> String {
|
||||
switch o {
|
||||
case .none: return "none"
|
||||
case .speaker: return "speaker"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected input port object, if any.
|
||||
var selectedPort: IOSAudioInputPort? {
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
import AVFoundation
|
||||
import Darwin
|
||||
import os
|
||||
import VoiceCatCore
|
||||
|
||||
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioEngine")
|
||||
|
||||
/// In-process single-producer/single-consumer int16 PCM ring for the playback path.
|
||||
///
|
||||
/// producer = the core's mixer-timer thread (the `vc_set_mixed_output_sink` callback)
|
||||
/// consumer = the `AVAudioSourceNode` render thread
|
||||
///
|
||||
/// Heap-backed (not shared memory like `BroadcastAudioRing`), but the same discipline: aligned
|
||||
/// 64-bit monotonic indices with `OSMemoryBarrier` for acquire/release ordering. Both the C
|
||||
/// callback and the render block are real-time — they only do index math + a memcpy here, never
|
||||
/// lock or allocate.
|
||||
final class PCMRing {
|
||||
private let data: UnsafeMutablePointer<Int16>
|
||||
private let capacity: Int
|
||||
private var writeIdx: UInt64 = 0
|
||||
private var readIdx: UInt64 = 0
|
||||
|
||||
init(capacitySamples: Int) {
|
||||
capacity = capacitySamples
|
||||
data = UnsafeMutablePointer<Int16>.allocate(capacity: capacitySamples)
|
||||
data.initialize(repeating: 0, count: capacitySamples)
|
||||
}
|
||||
deinit { data.deallocate() }
|
||||
|
||||
/// Producer: append `count` interleaved int16 samples. Drops the chunk if it doesn't fit
|
||||
/// (better to skip than tear). Single producer only (the core mixer-timer thread).
|
||||
func write(_ src: UnsafePointer<Int16>, count: Int) {
|
||||
guard count > 0, count <= capacity else { return }
|
||||
let w = writeIdx
|
||||
OSMemoryBarrier()
|
||||
let r = readIdx
|
||||
if capacity - Int(w &- r) < count { return } // full: drop
|
||||
var idx = Int(w % UInt64(capacity))
|
||||
var off = 0
|
||||
var rem = count
|
||||
while rem > 0 {
|
||||
let chunk = min(rem, capacity - idx)
|
||||
(data + idx).update(from: src + off, count: chunk)
|
||||
idx = (idx + chunk) % capacity
|
||||
off += chunk
|
||||
rem -= chunk
|
||||
}
|
||||
OSMemoryBarrier()
|
||||
writeIdx = w &+ UInt64(count)
|
||||
}
|
||||
|
||||
/// Consumer: read up to `count` interleaved int16 samples into `dst`; returns the number
|
||||
/// read (the rest is the caller's to silence-fill). Single consumer only (render thread).
|
||||
func read(into dst: UnsafeMutablePointer<Int16>, count: Int) -> Int {
|
||||
let r = readIdx
|
||||
OSMemoryBarrier()
|
||||
let w = writeIdx
|
||||
let available = Int(w &- r)
|
||||
if available <= 0 { return 0 }
|
||||
let n = min(available, count)
|
||||
var idx = Int(r % UInt64(capacity))
|
||||
var off = 0
|
||||
var rem = n
|
||||
while rem > 0 {
|
||||
let chunk = min(rem, capacity - idx)
|
||||
(dst + off).update(from: data + idx, count: chunk)
|
||||
idx = (idx + chunk) % capacity
|
||||
off += chunk
|
||||
rem -= chunk
|
||||
}
|
||||
OSMemoryBarrier()
|
||||
readIdx = r &+ UInt64(n)
|
||||
return n
|
||||
}
|
||||
|
||||
/// Consumer-side snapshot of how many interleaved int16 samples are currently buffered. Lets a
|
||||
/// paced consumer check for a full frame *before* calling `read`, so it never reads (and thus
|
||||
/// discards) a partial frame. Single consumer only (same thread that calls `read`).
|
||||
var availableSamples: Int {
|
||||
let r = readIdx
|
||||
OSMemoryBarrier()
|
||||
let w = writeIdx
|
||||
return Int(w &- r)
|
||||
}
|
||||
|
||||
/// Discard everything buffered — call before (re)starting so stale pre-roll isn't played.
|
||||
func reset() { OSMemoryBarrier(); readIdx = writeIdx }
|
||||
|
||||
/// Diagnostics: monotonic total samples written / read since the ring was created. The
|
||||
/// indices are already cumulative, so these are free. Only read them when both threads are
|
||||
/// quiesced (e.g. at teardown after the engine + mixer sink are stopped) — they are not
|
||||
/// synchronized for live cross-thread reads. Lets us tell "core never delivered PCM" apart
|
||||
/// from "PCM arrived but produced no sound" (the AVAudioEngine output graph).
|
||||
var debugTotalWritten: UInt64 { writeIdx }
|
||||
var debugTotalRead: UInt64 { readIdx }
|
||||
}
|
||||
|
||||
/// The single iOS audio engine (docs/voice.md §8 "iOS audio engine").
|
||||
///
|
||||
/// **One path, always external.** On iOS the core never opens a miniaudio device: a MIC stream is
|
||||
/// always started with `external_feed=1`, `vc_set_external_playback(1)` is set once at connect, and
|
||||
/// this engine drives *both* directions through one `AVAudioEngine`:
|
||||
/// - **core → speaker:** the core's mixed-output sink fills `ring`; an `AVAudioSourceNode` pulls
|
||||
/// from it and renders through the engine output. This runs the whole time we're connected,
|
||||
/// so remote audio plays even before the user joins voice (no "can't hear anyone").
|
||||
/// - **mic → core:** when the mic is active a tap on the input node converts to 48 kHz int16 and
|
||||
/// writes to a pacing ring; a 20 ms timer releases steady 960-sample frames to
|
||||
/// `client.feedPcm(micStreamId)`. The core sends each captured frame synchronously, so this
|
||||
/// steady cadence is what keeps packets from bursting and fluttering the receiver's playout.
|
||||
///
|
||||
/// Echo cancellation / noise suppression / AGC come from Apple's Voice-Processing I/O unit (VPIO),
|
||||
/// which `inputNode.setVoiceProcessingEnabled(true)` enables. VPIO forces mono, so it is engaged
|
||||
/// only when the active preset wants it (`IOSAudioRouter.currentConfigUsesVoiceProcessing`) — the
|
||||
/// Stereo Mic / A2DP configs run the same engine with VPIO off.
|
||||
///
|
||||
/// Every preset / route / interruption change funnels through `reconfigure()`: a single
|
||||
/// deterministic stop → AVAudioSession reconfigure → rebuild graph → start. There is no second
|
||||
/// (miniaudio) audio path to hand off to, so a switch cannot leave one direction dropped.
|
||||
@MainActor
|
||||
final class IOSAudioEngine {
|
||||
static let shared = IOSAudioEngine()
|
||||
|
||||
/// True while connected (between `startListening` and `stop`) — the playback graph should run.
|
||||
private(set) var isConnected = false
|
||||
/// True while a local mic stream is active — the input tap should be installed.
|
||||
private(set) var micActive = false
|
||||
|
||||
private let engine = AVAudioEngine()
|
||||
private var sourceNode: AVAudioSourceNode?
|
||||
private weak var client: VoiceCatClient?
|
||||
private var micStreamId: UInt32 = 0
|
||||
private var captureChannels: UInt32 = 1
|
||||
|
||||
// AVAudioEngine may deliver several codec frames per callback. Pace complete 20 ms frames
|
||||
// through an SPSC ring; never consume partial frames, and recreate the timer when the channel
|
||||
// count changes.
|
||||
private let micRing = PCMRing(capacitySamples: 48000 * 2) // ~1 s stereo — ample elastic slack
|
||||
private var micTimer: DispatchSourceTimer?
|
||||
private let micQueue = DispatchQueue(label: "cat.voice.mic.feedPump")
|
||||
private let micDrainScratch: UnsafeMutablePointer<Int16>
|
||||
private static let micFrameSamplesPerChannel = 960 // 20 ms @ 48 kHz — core's frame size
|
||||
|
||||
/// Feed-pump state, touched only on `micQueue` (the pump's serial queue). A reference type so
|
||||
/// the timer closure mutates it without capturing `self` (which is @MainActor). `targetFrames`
|
||||
/// is the prebuffer depth: the pump fills this many frames before it starts releasing, so the
|
||||
/// tap's bursty delivery (~2 frames at once) can't drain it to empty between bursts. It persists
|
||||
/// across rebuilds and self-heals upward (capped) on an underrun, so it tunes to whatever IO
|
||||
/// buffer size the active route/VPIO actually uses without a hard-coded guess.
|
||||
private final class PumpState {
|
||||
var primed = false
|
||||
var targetFrames = 3 // ~60 ms initial cushion; grows on underrun up to maxTargetFrames
|
||||
static let maxTargetFrames = 6 // ~120 ms cap — bounds added latency
|
||||
}
|
||||
private let pumpState = PumpState()
|
||||
|
||||
// 48 kHz stereo Float32 (deinterleaved) — the format the source node renders. The core
|
||||
// delivers 48 kHz stereo int16 via the mixed-output sink; mainMixerNode adapts to the route.
|
||||
private let outFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: 48000, channels: 2, interleaved: false)!
|
||||
|
||||
// Playback ring (mixed remote audio): ~0.5 s of 48 kHz stereo int16. Filled by the core's
|
||||
// mixer-timer thread, drained by the source-node render thread.
|
||||
private let ring = PCMRing(capacitySamples: 48000 * 2 / 2)
|
||||
// Render-thread scratch for deinterleaving — pre-allocated so the render block never allocates.
|
||||
private let renderScratchFrames = 8192
|
||||
private let renderScratch: UnsafeMutablePointer<Int16>
|
||||
|
||||
private init() {
|
||||
renderScratch = UnsafeMutablePointer<Int16>.allocate(capacity: renderScratchFrames * 2)
|
||||
renderScratch.initialize(repeating: 0, count: renderScratchFrames * 2)
|
||||
micDrainScratch = UnsafeMutablePointer<Int16>.allocate(capacity: 960 * 2)
|
||||
micDrainScratch.initialize(repeating: 0, count: 960 * 2)
|
||||
|
||||
// AVAudioEngine stops itself on a mid-session route/configuration change (it stops
|
||||
// if its I/O graph no longer matches the active route). Our route-change handler in
|
||||
// AudioSessionManager normally rebuilds us before the user notices, but if the engine
|
||||
// stops itself AFTER our recovery (because the route-change notification raced ahead
|
||||
// of the engine's own self-stop), nothing restarts it. Catch that case here.
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleEngineConfigurationChange),
|
||||
name: .AVAudioEngineConfigurationChange, object: engine)
|
||||
}
|
||||
|
||||
/// The engine stopped itself because its configuration no longer matches the active AVAudio
|
||||
/// route (this fires after a route change that the route-change handler can't always outrun).
|
||||
/// Dispatch to main and call the unified `recoverAudio()` — it's intent-gated on
|
||||
/// `isConnected`, idempotent, and no-ops if the engine is already running (the common case
|
||||
/// where our route-change handler got there first).
|
||||
@objc private func handleEngineConfigurationChange(_ notification: Notification) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
guard self.isConnected, !self.engine.isRunning else { return }
|
||||
logger.info("engine configuration-change — engine stopped itself, recovering")
|
||||
AudioSessionManager.shared.recoverAudio()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Begin playback-only (listening) operation. Called once at connect, after
|
||||
/// `client.setExternalPlayback(true)` and `AudioSessionManager.ensureSessionActive()`. Attaches
|
||||
/// the source node, wires the core's mixed-output sink into the ring, and starts the engine so
|
||||
/// remote audio plays immediately.
|
||||
func startListening(client: VoiceCatClient) {
|
||||
self.client = client
|
||||
guard !isConnected else { return }
|
||||
isConnected = true
|
||||
ring.reset()
|
||||
|
||||
// Wire the core's mixed-output sink into the ring (C function pointer, no captures). Stays
|
||||
// registered for the whole connection; the ring is drained by the source-node render block.
|
||||
let ringPtr = Unmanaged.passUnretained(self.ring).toOpaque()
|
||||
client.setMixedOutputSink({ user, pcm, spc, ch, _ in
|
||||
guard let user, let pcm else { return }
|
||||
let ring = Unmanaged<PCMRing>.fromOpaque(user).takeUnretainedValue()
|
||||
ring.write(pcm, count: spc * Int(ch))
|
||||
}, user: ringPtr)
|
||||
|
||||
rebuild()
|
||||
}
|
||||
|
||||
/// Tear down the engine and unhook the core sink. Called on disconnect.
|
||||
func stop() {
|
||||
guard isConnected else { return }
|
||||
micActive = false
|
||||
stopMicTimer()
|
||||
isConnected = false
|
||||
client?.setMixedOutputSink(nil, user: nil)
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
if engine.isRunning { engine.stop() }
|
||||
logger.info("audio engine stopped — ring written=\(self.ring.debugTotalWritten) read=\(self.ring.debugTotalRead) samples")
|
||||
try? engine.inputNode.setVoiceProcessingEnabled(false)
|
||||
if let src = sourceNode {
|
||||
engine.detach(src)
|
||||
sourceNode = nil
|
||||
}
|
||||
ring.reset()
|
||||
micRing.reset()
|
||||
client = nil
|
||||
}
|
||||
|
||||
// MARK: - Mic transitions
|
||||
|
||||
/// Engage the mic: install the input tap and (if the preset wants it) VPIO. Called when the
|
||||
/// user joins voice, after the MIC stream (external_feed) is started.
|
||||
func startMic(streamId: UInt32, channels: UInt32) {
|
||||
micStreamId = streamId
|
||||
captureChannels = channels
|
||||
micActive = true
|
||||
rebuild()
|
||||
}
|
||||
|
||||
/// Disengage the mic: remove the tap and VPIO, keep playback running for remaining remote audio.
|
||||
func stopMic() {
|
||||
guard micActive else { return }
|
||||
micActive = false
|
||||
rebuild()
|
||||
}
|
||||
|
||||
/// Update the capture channel count (mono↔stereo) for the active mic and rebuild.
|
||||
func setCaptureChannels(_ channels: UInt32) {
|
||||
captureChannels = channels
|
||||
if let client, micStreamId != 0 {
|
||||
client.setCaptureChannels(streamId: micStreamId, channels: channels)
|
||||
}
|
||||
if micActive { rebuild() }
|
||||
}
|
||||
|
||||
/// Re-apply the engine graph against the current AVAudioSession config (preset / route change).
|
||||
/// Safe to call when only listening — it just rebuilds the playback graph against the new route.
|
||||
func reconfigure() {
|
||||
guard isConnected else { return }
|
||||
rebuild()
|
||||
}
|
||||
|
||||
// MARK: - Graph (re)build
|
||||
|
||||
/// The single place that (re)builds and starts the engine graph. Deterministic: stop → set
|
||||
/// VPIO → (re)install the mic tap → start. The caller is responsible for having applied the
|
||||
/// AVAudioSession config (category/mode/route) first (`IOSAudioRouter.applyConfiguration`).
|
||||
private func rebuild() {
|
||||
guard isConnected else { return }
|
||||
// Stop the feed pump before touching the tap / ring so the timer (on micQueue) can't race
|
||||
// the ring reset in installMicTap. It is restarted at the end with the current channel count.
|
||||
stopMicTimer()
|
||||
if engine.isRunning { engine.stop() }
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
|
||||
let useVPIO = micActive && IOSAudioRouter.shared.currentConfigUsesVoiceProcessing
|
||||
do {
|
||||
try engine.inputNode.setVoiceProcessingEnabled(useVPIO)
|
||||
} catch {
|
||||
logger.error("setVoiceProcessingEnabled(\(useVPIO)) failed: \(error.localizedDescription)")
|
||||
}
|
||||
if useVPIO {
|
||||
// AGC is the one VPIO sub-stage iOS exposes; AEC+NS are bundled into the master switch.
|
||||
engine.inputNode.isVoiceProcessingAGCEnabled = IOSAudioRouter.shared.agcEnabled
|
||||
}
|
||||
|
||||
// The source node must bind to the selected voice-processing output unit.
|
||||
rebuildSourceNode()
|
||||
if micActive { installMicTap() }
|
||||
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
let inFmt = engine.inputNode.outputFormat(forBus: 0)
|
||||
let outFmt = engine.outputNode.outputFormat(forBus: 0)
|
||||
let route = AVAudioSession.sharedInstance().currentRoute.outputs
|
||||
.map { "\($0.portName)[\($0.portType.rawValue)]" }.joined(separator: ", ")
|
||||
logger.info("""
|
||||
engine started — mic=\(self.micActive) vpio=\(useVPIO) captureCh=\(self.captureChannels) \
|
||||
inFormat=\(inFmt) outputNode=\(outFmt) outputRoute=[\(route)]
|
||||
""")
|
||||
} catch {
|
||||
// Route changes can leave AVAudioSession inactive; retry once after reactivation.
|
||||
logger.error("engine start failed: \(error.localizedDescription) — attempting one-shot recovery")
|
||||
do {
|
||||
try AudioSessionManager.shared.ensureSessionActive()
|
||||
} catch {
|
||||
logger.error("recovery — session re-activate failed: \(error.localizedDescription)")
|
||||
}
|
||||
IOSAudioRouter.shared.applyConfiguration()
|
||||
if AudioSessionManager.shared.isActive {
|
||||
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
|
||||
}
|
||||
do {
|
||||
try engine.start()
|
||||
logger.info("engine start succeeded after one-shot recovery")
|
||||
} catch {
|
||||
logger.error("engine start failed after recovery: \(error.localizedDescription)")
|
||||
// Not fatal — a subsequent route-change or AVAudioEngine configuration-change
|
||||
// notification will trigger recoverAudio() and re-attempt the rebuild.
|
||||
}
|
||||
}
|
||||
|
||||
// Start the feed pump last, with the current channel count, so it never carries a stale
|
||||
// (frozen) channel count across a mono↔stereo switch.
|
||||
if micActive { startMicTimer() }
|
||||
}
|
||||
|
||||
/// Detach any previous source node and attach a fresh one pulling mixed PCM from the ring.
|
||||
/// Rebuilt on every graph rebuild so it always connects against the current output unit (the
|
||||
/// VPIO state can change the output between rebuilds). Its format is route-independent —
|
||||
/// `mainMixerNode` adapts 48 kHz stereo to whatever the output route is.
|
||||
private func rebuildSourceNode() {
|
||||
if let old = sourceNode {
|
||||
engine.detach(old)
|
||||
sourceNode = nil
|
||||
}
|
||||
let ring = self.ring
|
||||
let scratch = self.renderScratch
|
||||
let scratchFrames = self.renderScratchFrames
|
||||
let src = AVAudioSourceNode(format: outFormat) { _, _, frameCount, ablPtr in
|
||||
let frames = Int(frameCount)
|
||||
let abl = UnsafeMutableAudioBufferListPointer(ablPtr)
|
||||
let n = min(frames, scratchFrames)
|
||||
let got = ring.read(into: scratch, count: n * 2) / 2 // interleaved stereo → frames
|
||||
let scale: Float = 1.0 / 32768.0
|
||||
for ch in 0..<abl.count {
|
||||
guard let base = abl[ch].mData?.assumingMemoryBound(to: Float.self) else { continue }
|
||||
for i in 0..<frames {
|
||||
base[i] = i < got ? Float(scratch[i * 2 + min(ch, 1)]) * scale : 0
|
||||
}
|
||||
}
|
||||
return noErr
|
||||
}
|
||||
sourceNode = src
|
||||
engine.attach(src)
|
||||
engine.connect(src, to: engine.mainMixerNode, format: outFormat)
|
||||
}
|
||||
|
||||
/// Install the mic tap: convert the input node's native format to 48 kHz int16 (mono or
|
||||
/// stereo per `captureChannels`) and write it to the pacing ring. The 20 ms feed pump
|
||||
/// (`startMicTimer`) releases steady 960-sample frames to `feedPcm` — see the mic-feed comment
|
||||
/// above for why the tap must NOT call feedPcm directly (it bursts packets → receiver flutter).
|
||||
/// Rebuilds the converter each time because the input format depends on the VPIO state and the
|
||||
/// active route.
|
||||
private func installMicTap() {
|
||||
guard client != nil else { return }
|
||||
// Fresh ring on every (re)install — a rebuild must not feed stale pre-roll into the new tap.
|
||||
// Safe here: the feed pump was stopped at the top of rebuild(), so no consumer is running.
|
||||
micRing.reset()
|
||||
let ring = micRing // captured by the closure as a `let` — no self capture (see mic-feed comment)
|
||||
let inFormat = engine.inputNode.outputFormat(forBus: 0)
|
||||
guard inFormat.sampleRate > 0 else {
|
||||
logger.error("input format unavailable (\(inFormat)) — mic will not transmit")
|
||||
return
|
||||
}
|
||||
let targetCh = max(1, min(2, captureChannels))
|
||||
guard let target = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000,
|
||||
channels: AVAudioChannelCount(targetCh), interleaved: true),
|
||||
let converter = AVAudioConverter(from: inFormat, to: target) else {
|
||||
logger.error("mic converter unavailable (in=\(inFormat), ch=\(targetCh)) — mic will not transmit")
|
||||
return
|
||||
}
|
||||
|
||||
let chInt = Int(targetCh)
|
||||
engine.inputNode.installTap(onBus: 0, bufferSize: 960, format: inFormat) { buffer, _ in
|
||||
// Convert this tap buffer to 48 kHz int16. Output capacity scaled for any upsample.
|
||||
let ratio = target.sampleRate / buffer.format.sampleRate
|
||||
let outCap = AVAudioFrameCount(Double(buffer.frameLength) * ratio + 16)
|
||||
guard let outBuf = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outCap) else { return }
|
||||
var fed = false
|
||||
let status = converter.convert(to: outBuf, error: nil) { _, outStatus in
|
||||
if fed { outStatus.pointee = .noDataNow; return nil }
|
||||
fed = true
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
guard status != .error, outBuf.frameLength > 0,
|
||||
let chData = outBuf.int16ChannelData else { return }
|
||||
// int16 interleaved → channelData[0] is the interleaved buffer. Write the converter's
|
||||
// variable-length output to the pacing ring; the 20 ms feed pump releases steady
|
||||
// 960-sample frames to feedPcm so packets leave the core at a steady 20 ms cadence.
|
||||
ring.write(chData[0], count: Int(outBuf.frameLength) * chInt)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mic feed pump (paces feedPcm at a steady 20 ms cadence)
|
||||
|
||||
/// Start the 20 ms feed pump. After priming a small cushion (`pumpState.targetFrames`), it
|
||||
/// releases ONE 960-sample frame per tick from `micRing` to `feedPcm`, so the core (which sends
|
||||
/// synchronously per captured frame) emits packets at a steady 20 ms — the cadence its receivers
|
||||
/// expect. The cushion is essential: the receiver's playout deliberately keeps near-zero
|
||||
/// buffering (low latency), so it tolerates a steady stream but not bursts; the iOS tap delivers
|
||||
/// ~2 frames at once, and without the cushion the pump runs at ~0 depth and underruns on every
|
||||
/// tap/timer phase beat (crackle). Recreated on every `rebuild()` so `ch` always reflects the
|
||||
/// current `captureChannels` (mono↔stereo switches). Captures only locals + the reference-type
|
||||
/// ring/client/state (no `self`, which is @MainActor).
|
||||
private func startMicTimer() {
|
||||
stopMicTimer()
|
||||
guard let client else { return }
|
||||
let ring = micRing
|
||||
let scratch = micDrainScratch
|
||||
let state = pumpState
|
||||
let sid = micStreamId
|
||||
let ch = max(1, min(2, Int(captureChannels)))
|
||||
let frameSamples = Self.micFrameSamplesPerChannel
|
||||
let full = frameSamples * ch
|
||||
let chU32 = UInt32(ch)
|
||||
// The ring was just reset in installMicTap, so the cushion must be refilled before sending.
|
||||
state.primed = false
|
||||
let feed: () -> Void = {
|
||||
_ = ring.read(into: scratch, count: full) // caller guarantees a full frame is present
|
||||
_ = client.feedPcm(streamId: sid, pcm: scratch,
|
||||
samplesPerChannel: frameSamples, channels: chU32)
|
||||
}
|
||||
let t = DispatchSource.makeTimerSource(queue: micQueue)
|
||||
t.schedule(deadline: .now(), repeating: .milliseconds(20), leeway: .milliseconds(2))
|
||||
t.setEventHandler {
|
||||
let frames = ring.availableSamples / full // whole frames currently buffered
|
||||
if !state.primed {
|
||||
if frames < state.targetFrames { return } // still filling the cushion (into silence)
|
||||
state.primed = true
|
||||
} else if frames == 0 {
|
||||
// Re-prime with a larger cushion; consuming a partial frame would lose samples.
|
||||
if state.targetFrames < PumpState.maxTargetFrames { state.targetFrames += 1 }
|
||||
state.primed = false
|
||||
return
|
||||
}
|
||||
feed() // one steady frame per tick (frames >= 1 here)
|
||||
// Catch-up: if the backlog grew past the cushion (pump descheduled, or producer ran
|
||||
// ahead via a burst), release one extra frame to drain it and keep latency bounded.
|
||||
if frames - 1 > state.targetFrames + 1 { feed() }
|
||||
}
|
||||
t.resume()
|
||||
micTimer = t
|
||||
}
|
||||
|
||||
private func stopMicTimer() {
|
||||
micTimer?.cancel()
|
||||
micTimer = nil
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,8 @@
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
|
||||
@@ -6,17 +6,22 @@ struct SavedServer: Codable, Identifiable, Equatable {
|
||||
var port: UInt16
|
||||
var authMode: AuthMode
|
||||
var savedUsername: String
|
||||
/// Free-form display name used when connecting as a guest. Distinct from the account
|
||||
/// `savedUsername`. Empty falls back to a default. Optional for backward-compatible decoding.
|
||||
var nickname: String?
|
||||
var keychainTag: String
|
||||
|
||||
enum AuthMode: String, Codable { case guest, password }
|
||||
|
||||
init(id: UUID = UUID(), host: String, port: UInt16,
|
||||
authMode: AuthMode = .guest, savedUsername: String = "", keychainTag: String = "") {
|
||||
authMode: AuthMode = .guest, savedUsername: String = "",
|
||||
nickname: String? = nil, keychainTag: String = "") {
|
||||
self.id = id
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.authMode = authMode
|
||||
self.savedUsername = savedUsername
|
||||
self.nickname = nickname
|
||||
self.keychainTag = keychainTag.isEmpty ? id.uuidString : keychainTag
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,15 @@ struct ActivityEntry: Identifiable {
|
||||
|
||||
struct VoiceState {
|
||||
var micActive = false
|
||||
var voiceSubscribed = false
|
||||
var selfMuted = false
|
||||
var selfDeafened = false
|
||||
var serverMuted = false
|
||||
var serverDeafened = false
|
||||
var inputMode: VoiceCatInputMode = .voiceActivation
|
||||
var vadThreshold: Float = 0.025
|
||||
var inputGain: Float = 1.0
|
||||
var inputNoiseReduction: Bool = false
|
||||
var level: Float = 0.0
|
||||
var currentDeviceId: String?
|
||||
var localStreamId: UInt32 = 0
|
||||
@@ -51,6 +54,30 @@ final class SessionState {
|
||||
var accounts: [Account] = []
|
||||
var devices: [Device] = []
|
||||
|
||||
/// Back-reference to the app state. Once `SessionState.init` overwrites `client.onEvent`,
|
||||
/// `AppState.handleConnectEvent` no longer receives per-session events — so the
|
||||
/// `.disconnected` event for a LIVE session arrives here in `handleEvent`, not in AppState.
|
||||
/// This weak ref lets us hand the disconnect back to AppState (which owns the reconnect
|
||||
/// state machine) so the auto-reconnect fires. Set by AppState on auth success.
|
||||
weak var appState: AppState?
|
||||
|
||||
// MARK: - Reconnect restore state
|
||||
//
|
||||
// When the iOS client auto-reconnects after a network drop, AppState captures the prior
|
||||
// session's channel + voice/mic state and asks the new SessionState (created on auth success)
|
||||
// to restore it. We rejoin the channel explicitly (the server auto-placed us in Lobby on
|
||||
// auth), and on the resulting `.joinResult` we re-arm voice subscription + mute/deafen. The
|
||||
// drive is here, not in AppState, because once SessionState is created it owns
|
||||
// `client.onEvent` and AppState no longer sees per-session events.
|
||||
private struct RestoreRequest {
|
||||
let channelId: UInt32
|
||||
let voiceSubscribed: Bool
|
||||
let micMuted: Bool
|
||||
let deafened: Bool
|
||||
}
|
||||
private var pendingRestore: RestoreRequest?
|
||||
private var didIssueRestoreJoin = false
|
||||
|
||||
/// Host side of iOS screen-audio sharing — drains the broadcast extension's App Group ring
|
||||
/// and feeds the SCREEN_AUDIO stream this session owns. See BroadcastAudioPump.
|
||||
private let broadcastPump = BroadcastAudioPump()
|
||||
@@ -59,7 +86,7 @@ final class SessionState {
|
||||
self.client = client
|
||||
self.selfUserId = selfUserId
|
||||
self.permissions = permissions
|
||||
AudioSessionManager.shared.client = client
|
||||
loadAndApplyVoiceSettings()
|
||||
refreshChannels()
|
||||
refreshUsers()
|
||||
syncSelfChannel()
|
||||
@@ -77,9 +104,6 @@ final class SessionState {
|
||||
|
||||
deinit {
|
||||
broadcastPump.stop()
|
||||
MainActor.assumeIsolated {
|
||||
AudioSessionManager.shared.client = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event dispatch
|
||||
@@ -89,7 +113,22 @@ final class SessionState {
|
||||
case .channelList:
|
||||
refreshChannels()
|
||||
syncSelfChannel()
|
||||
case .userJoined, .userLeft:
|
||||
case .userJoined:
|
||||
// ev.text = nickname, ev.channelId = the channel they joined (per voicecat.h).
|
||||
if ev.userId != selfUserId && ev.channelId == currentChannelId {
|
||||
EventFeedback.shared.play(.channelJoin)
|
||||
EventFeedback.shared.speak("\(ev.text ?? "Someone") joined")
|
||||
}
|
||||
refreshUsers()
|
||||
syncSelfChannel()
|
||||
case .userLeft:
|
||||
// Capture the leaving user's prior nickname/channel before refreshUsers() drops them.
|
||||
if ev.userId != selfUserId,
|
||||
let gone = users.first(where: { $0.id == ev.userId }),
|
||||
gone.channelId == currentChannelId {
|
||||
EventFeedback.shared.play(.channelLeave)
|
||||
EventFeedback.shared.speak("\(gone.nickname) left")
|
||||
}
|
||||
refreshUsers()
|
||||
syncSelfChannel()
|
||||
case .userUpdated:
|
||||
@@ -100,13 +139,27 @@ final class SessionState {
|
||||
}
|
||||
case .textMessage:
|
||||
let sender = users.first(where: { $0.id == ev.userId })?.nickname ?? "Unknown"
|
||||
let body = ev.text ?? ""
|
||||
let isSelf = ev.userId == selfUserId
|
||||
let isPrivate = ev.textScope == .private
|
||||
messages.append(ChatMessage(
|
||||
timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000),
|
||||
senderName: sender,
|
||||
text: ev.text ?? "",
|
||||
text: body,
|
||||
scope: ev.textScope))
|
||||
EventFeedback.shared.play(isPrivate
|
||||
? (isSelf ? .pmSent : .pmRecv)
|
||||
: (isSelf ? .channelSent : .channelRecv))
|
||||
if !isSelf {
|
||||
EventFeedback.shared.speak(isPrivate
|
||||
? "Private message from \(sender): \(body)"
|
||||
: "\(sender): \(body)")
|
||||
}
|
||||
case .talkState:
|
||||
let talking = ev.u32a != 0
|
||||
if ev.userId == selfUserId {
|
||||
EventFeedback.shared.play(talking ? .vaStart : .vaStop)
|
||||
}
|
||||
let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)"
|
||||
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
|
||||
case .streamStarted:
|
||||
@@ -123,8 +176,6 @@ final class SessionState {
|
||||
addActivity("Sharing screen audio (\(channels == 2 ? "stereo" : "mono"))")
|
||||
break
|
||||
}
|
||||
// A remote user started a stream — ensure the audio session is active so we can
|
||||
// hear them even if we haven't joined voice ourselves.
|
||||
if ev.userId != selfUserId {
|
||||
do {
|
||||
try AudioSessionManager.shared.ensureSessionActive()
|
||||
@@ -135,14 +186,48 @@ final class SessionState {
|
||||
AudioSessionManager.shared.logSessionState("stream started (user \(ev.userId))")
|
||||
addActivity("Stream started (user \(ev.userId))")
|
||||
case .streamStopped:
|
||||
if ev.userId == selfUserId {
|
||||
if ev.streamId == voiceState.localStreamId {
|
||||
voiceState.localStreamId = 0
|
||||
voiceState.micActive = false
|
||||
voiceState.level = 0
|
||||
}
|
||||
} else {
|
||||
addActivity("Stream stopped (user \(ev.userId))")
|
||||
}
|
||||
case .voiceState:
|
||||
let subscribed = ev.u32a != 0
|
||||
voiceState.voiceSubscribed = subscribed
|
||||
if subscribed {
|
||||
doStartMicStream()
|
||||
} else {
|
||||
voiceState.micActive = false
|
||||
voiceState.level = 0
|
||||
EventFeedback.shared.play(.voiceOff)
|
||||
}
|
||||
case .joinResult:
|
||||
if ev.result == .ok {
|
||||
currentChannelId = ev.channelId
|
||||
addActivity("Joined channel")
|
||||
refreshUsers()
|
||||
// Reconnect restore: this was our restore-join. Now that the server has
|
||||
// processed the channel move, re-arm voice subscription (if the user was
|
||||
// transmitting before the drop) and re-apply the local mute/deafen state.
|
||||
// The server returns ok even when joining the channel we're already in, so
|
||||
// this fires reliably for the Lobby-too case.
|
||||
if didIssueRestoreJoin, let r = pendingRestore, r.channelId == ev.channelId {
|
||||
didIssueRestoreJoin = false
|
||||
completeRestore()
|
||||
}
|
||||
} else {
|
||||
addActivity("Join failed: \(ev.result.description)")
|
||||
// Restore-join failed (channel was deleted, became password-protected or
|
||||
// full while we were away). Give up on the voice/mute restore cleanly so we
|
||||
// don't leave dangling state or attempt voice without being in a channel.
|
||||
if didIssueRestoreJoin {
|
||||
didIssueRestoreJoin = false
|
||||
pendingRestore = nil
|
||||
}
|
||||
}
|
||||
case .error:
|
||||
addActivity("Error: \(ev.text ?? ev.result.description)")
|
||||
@@ -152,6 +237,16 @@ final class SessionState {
|
||||
}
|
||||
case .accountList:
|
||||
accounts = client.listAccounts()
|
||||
case .disconnected:
|
||||
// Audible cue, then hand the disconnect back to AppState so its reconnect state
|
||||
// machine fires. This is the ONLY way AppState learns a live session dropped —
|
||||
// after auth success, `SessionState.init` overwrites `client.onEvent`, so
|
||||
// `AppState.handleConnectEvent` never sees this event. (Without this callback, a
|
||||
// network drop on a live session would just play the cue and leave the session as a
|
||||
// zombie — the user would have to tap Disconnect manually.)
|
||||
EventFeedback.shared.play(ev.result == .ok ? .logout : .connectionLost)
|
||||
EventFeedback.shared.speak(ev.result == .ok ? "Disconnected" : "Connection lost")
|
||||
appState?.onLiveSessionDisconnected()
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -165,17 +260,18 @@ final class SessionState {
|
||||
// MARK: - Self-channel / server-mute sync
|
||||
|
||||
/// Sync currentChannelId from the self user's channelId in the user list. Mirrors macOS
|
||||
/// MainWindowController.swift:461,491,522. The server auto-places every authed user into
|
||||
/// the Lobby (channel 1) on connect, but without this sync currentChannelId stays 0 and
|
||||
/// the mic button (gated on currentChannelId == 0) stays permanently dimmed.
|
||||
/// MainWindowController's bootstrap/event-handling sync. The server auto-places every
|
||||
/// authed user into the Lobby (channel 1) on connect, but without this sync
|
||||
/// currentChannelId stays 0 and the mic button (gated on currentChannelId == 0) stays
|
||||
/// permanently dimmed.
|
||||
private func syncSelfChannel() {
|
||||
if let me = users.first(where: { $0.id == selfUserId }) {
|
||||
currentChannelId = me.channelId
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply server-side mute/deafen state — mirrors macOS MainWindowController.swift:693-700.
|
||||
/// iOS was previously ignoring server mute/deafen entirely.
|
||||
/// Apply server-side mute/deafen state — mirrors macOS MainWindowController's handling
|
||||
/// of UserEvent.UPDATED for the self user.
|
||||
private func applyServerMuteState(muted: Bool, deafened: Bool) {
|
||||
if muted && !voiceState.serverMuted { addActivity("You have been server-muted") }
|
||||
if deafened && !voiceState.serverDeafened { addActivity("You have been server-deafened") }
|
||||
@@ -202,12 +298,15 @@ final class SessionState {
|
||||
currentChannelId = 0
|
||||
}
|
||||
|
||||
func startMicStream() {
|
||||
func joinVoice() {
|
||||
AVAudioApplication.requestRecordPermission { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
if granted {
|
||||
self.doStartMicStream()
|
||||
let result = self.client.joinVoice()
|
||||
if result != .ok {
|
||||
self.addActivity("Failed to join voice: \(result.description)")
|
||||
}
|
||||
} else {
|
||||
self.addActivity("Microphone permission denied — grant in Settings > Privacy > Microphone")
|
||||
}
|
||||
@@ -222,41 +321,73 @@ final class SessionState {
|
||||
addActivity("AVAudioSession activate failed: \(error)")
|
||||
return
|
||||
}
|
||||
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic")
|
||||
|
||||
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic",
|
||||
externalFeed: true)
|
||||
let (result, streamId) = client.startStream(desc)
|
||||
if result == .ok {
|
||||
guard result == .ok else {
|
||||
addActivity("Failed to start mic: \(result.description)")
|
||||
return
|
||||
}
|
||||
voiceState.micActive = true
|
||||
voiceState.localStreamId = streamId
|
||||
// Publish the active mic stream ID so IOSAudioRouter can reset the core's capture
|
||||
// channel count when the user switches mono↔stereo (selectCaptureChannels /
|
||||
// applyPreset). Without this, switching stereo→mono leaves the LocalStream's
|
||||
// capture_channels field at 2 and the next engine start still opens stereo.
|
||||
AudioSessionManager.shared.activeMicStreamId = streamId
|
||||
// Store the user's capture channel selection before the server acknowledges
|
||||
// the stream. The engine hasn't started yet at this point (it starts when
|
||||
// handle_stream_announce_result fires), so vc_set_capture_channels just
|
||||
// stores the value — no restart. ensure_audio_running() picks it up when
|
||||
// the stream is confirmed and opens the device with the right channel count.
|
||||
EventFeedback.shared.play(.voiceOn)
|
||||
|
||||
let channels = IOSAudioRouter.shared.captureChannels.channelCount
|
||||
if channels != 1 {
|
||||
client.setCaptureChannels(streamId: streamId, channels: channels)
|
||||
}
|
||||
IOSAudioEngine.shared.startMic(streamId: streamId, channels: channels)
|
||||
}
|
||||
|
||||
func leaveVoice() {
|
||||
if voiceState.screenStreamId != 0 { stopScreenShare() }
|
||||
client.setPushToTalk(false)
|
||||
IOSAudioEngine.shared.stopMic()
|
||||
client.leaveVoice()
|
||||
}
|
||||
|
||||
// MARK: - Reconnect restore
|
||||
|
||||
/// Called by `AppState` after a reconnect's auth success to rejoin the prior channel and
|
||||
/// re-enable the prior voice/mic state. Drives the restore through the `.joinResult` event
|
||||
/// so we re-arm voice only AFTER the server processed the join — joining voice before the
|
||||
/// channel move would be rejected server-side. `micMuted`/`deafened` are the user's LOCAL
|
||||
/// mute/deafen state at the moment of the drop; the server resets those on a fresh auth, so
|
||||
/// we re-push them via `setMute` after the channel is restored.
|
||||
func requestRestore(channelId: UInt32, voiceSubscribed: Bool,
|
||||
micMuted: Bool, deafened: Bool) {
|
||||
pendingRestore = RestoreRequest(channelId: channelId,
|
||||
voiceSubscribed: voiceSubscribed,
|
||||
micMuted: micMuted,
|
||||
deafened: deafened)
|
||||
didIssueRestoreJoin = false
|
||||
if channelId != 0 {
|
||||
// The server auto-placed us in the Lobby on auth; join our prior channel explicitly.
|
||||
// `vc_join_channel` is idempotent server-side (joining the channel you're already in
|
||||
// returns ok), so this is safe even if the prior channel was the Lobby.
|
||||
client.joinChannel(channelId)
|
||||
didIssueRestoreJoin = true
|
||||
} else {
|
||||
addActivity("Failed to start mic: \(result.description)")
|
||||
// No prior channel — go straight to the voice/mute restore. (voiceSubscribed with
|
||||
// channelId == 0 is contradictory; `completeRestore` further guards on
|
||||
// currentChannelId != 0 before subscribing to voice.)
|
||||
completeRestore()
|
||||
}
|
||||
}
|
||||
|
||||
func stopMicStream() {
|
||||
if voiceState.localStreamId != 0 {
|
||||
client.stopStream(voiceState.localStreamId)
|
||||
voiceState.localStreamId = 0
|
||||
AudioSessionManager.shared.activeMicStreamId = nil
|
||||
/// Finish the restore after the channel is in place (or there was no channel to restore):
|
||||
/// re-subscribe to voice if the user was transmitting, and re-apply the local mute/deafen
|
||||
/// state. Safe to call once per `pendingRestore`; clears it.
|
||||
private func completeRestore() {
|
||||
guard let r = pendingRestore else { return }
|
||||
if r.voiceSubscribed && currentChannelId != 0 {
|
||||
joinVoice()
|
||||
}
|
||||
voiceState.micActive = false
|
||||
voiceState.level = 0
|
||||
// Do NOT deactivate the AVAudioSession here — the user may still want to hear
|
||||
// remote audio (other people talking). The session is deactivated only when
|
||||
// disconnecting from the server (see AppState.disconnect / .disconnected event).
|
||||
setMute(r.micMuted, deafened: r.deafened)
|
||||
addActivity("Restored to channel \(currentChannelId)"
|
||||
+ (r.voiceSubscribed ? " with voice" : ""))
|
||||
pendingRestore = nil
|
||||
}
|
||||
|
||||
// MARK: - Screen audio share
|
||||
@@ -303,15 +434,65 @@ final class SessionState {
|
||||
func setInputMode(_ mode: VoiceCatInputMode) {
|
||||
client.setInputMode(mode)
|
||||
voiceState.inputMode = mode
|
||||
UserDefaults.standard.set(Int(mode.rawValue), forKey: DefaultsKey.inputMode)
|
||||
}
|
||||
|
||||
func setVadThreshold(_ threshold: Float) {
|
||||
client.setVadThreshold(threshold)
|
||||
voiceState.vadThreshold = threshold
|
||||
UserDefaults.standard.set(threshold, forKey: DefaultsKey.vadThreshold)
|
||||
}
|
||||
|
||||
func setInputGain(_ gain: Float) {
|
||||
client.setInputGain(gain)
|
||||
voiceState.inputGain = gain
|
||||
UserDefaults.standard.set(gain, forKey: DefaultsKey.inputGain)
|
||||
}
|
||||
|
||||
func setInputNoiseReduction(_ on: Bool) {
|
||||
client.setInputNoiseReduction(on)
|
||||
voiceState.inputNoiseReduction = on
|
||||
UserDefaults.standard.set(on, forKey: DefaultsKey.inputNoiseReduction)
|
||||
}
|
||||
|
||||
// MARK: - Persisted input settings
|
||||
|
||||
private enum DefaultsKey {
|
||||
static let inputMode = "voice.inputMode"
|
||||
static let vadThreshold = "voice.vadThreshold"
|
||||
static let inputGain = "voice.inputGain"
|
||||
static let inputNoiseReduction = "voice.inputNoiseReduction"
|
||||
}
|
||||
|
||||
/// Restore the saved input mode / VAD threshold / mic gain and push them into the core so a
|
||||
/// relaunch keeps the user's transmission settings instead of resetting to VAD defaults.
|
||||
private func loadAndApplyVoiceSettings() {
|
||||
let d = UserDefaults.standard
|
||||
if d.object(forKey: DefaultsKey.inputMode) != nil {
|
||||
let raw = UInt32(d.integer(forKey: DefaultsKey.inputMode))
|
||||
voiceState.inputMode = VoiceCatInputMode(rawValue: raw) ?? .voiceActivation
|
||||
}
|
||||
if d.object(forKey: DefaultsKey.vadThreshold) != nil {
|
||||
voiceState.vadThreshold = d.float(forKey: DefaultsKey.vadThreshold)
|
||||
}
|
||||
if d.object(forKey: DefaultsKey.inputGain) != nil {
|
||||
voiceState.inputGain = d.float(forKey: DefaultsKey.inputGain)
|
||||
}
|
||||
if d.object(forKey: DefaultsKey.inputNoiseReduction) != nil {
|
||||
voiceState.inputNoiseReduction = d.bool(forKey: DefaultsKey.inputNoiseReduction)
|
||||
}
|
||||
client.setInputMode(voiceState.inputMode)
|
||||
client.setVadThreshold(voiceState.vadThreshold)
|
||||
client.setInputGain(voiceState.inputGain)
|
||||
client.setInputNoiseReduction(voiceState.inputNoiseReduction)
|
||||
}
|
||||
|
||||
private var pttEngaged = false
|
||||
func setPushToTalk(_ active: Bool) {
|
||||
client.setPushToTalk(active)
|
||||
// Play the PTT cue only on the press transition (the gesture fires repeatedly while held).
|
||||
if active && !pttEngaged { EventFeedback.shared.play(.ptt) }
|
||||
pttEngaged = active
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
@@ -342,13 +523,14 @@ final class SessionState {
|
||||
client.setServerMute(userId, muted: muted, deafened: deafened)
|
||||
}
|
||||
|
||||
func createChannel(name: String, topic: String) {
|
||||
let info = ChannelEdit(id: 0, parentId: 0, name: name, topic: topic,
|
||||
passwordProtected: false, password: nil,
|
||||
maxUsers: 0, sortOrder: 0, audio: AudioConfig())
|
||||
func createChannel(_ info: ChannelEdit) {
|
||||
client.createChannel(info)
|
||||
}
|
||||
|
||||
func editChannel(_ info: ChannelEdit) {
|
||||
client.editChannel(info)
|
||||
}
|
||||
|
||||
func deleteChannel(_ channelId: UInt32) {
|
||||
client.deleteChannel(channelId)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ struct AddServerView: View {
|
||||
@State private var host = ""
|
||||
@State private var port = "7878"
|
||||
@State private var authMode = SavedServer.AuthMode.guest
|
||||
@State private var nickname = ""
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var savePassword = false
|
||||
@@ -35,6 +36,13 @@ struct AddServerView: View {
|
||||
.pickerStyle(.segmented)
|
||||
.accessibilityLabel("Authentication mode")
|
||||
|
||||
if authMode == .guest {
|
||||
TextField("Nickname (optional)", text: $nickname)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Guest nickname, optional display name")
|
||||
}
|
||||
|
||||
if authMode == .password {
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
@@ -67,6 +75,7 @@ struct AddServerView: View {
|
||||
port = "\(s.port)"
|
||||
authMode = s.authMode
|
||||
username = s.savedUsername
|
||||
nickname = s.nickname ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,17 +85,21 @@ struct AddServerView: View {
|
||||
guard !trimmedHost.isEmpty, let portNum = UInt16(port) else { return }
|
||||
|
||||
let pw = (savePassword && authMode == .password && !password.isEmpty) ? password : nil
|
||||
let trimmedNick = nickname.trimmingCharacters(in: .whitespaces)
|
||||
let nick: String? = (authMode == .guest && !trimmedNick.isEmpty) ? trimmedNick : nil
|
||||
|
||||
if var s = editing {
|
||||
s.host = trimmedHost
|
||||
s.port = portNum
|
||||
s.authMode = authMode
|
||||
s.savedUsername = authMode == .password ? username : ""
|
||||
s.nickname = nick
|
||||
appState.updateServer(s, password: pw)
|
||||
} else {
|
||||
let s = SavedServer(host: trimmedHost, port: portNum,
|
||||
authMode: authMode,
|
||||
savedUsername: authMode == .password ? username : "")
|
||||
savedUsername: authMode == .password ? username : "",
|
||||
nickname: nick)
|
||||
appState.addServer(s, password: pw)
|
||||
}
|
||||
dismiss()
|
||||
|
||||
@@ -7,6 +7,7 @@ import VoiceCatCore
|
||||
struct ChannelBrowserView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var showCreateChannel = false
|
||||
@State private var editChannel: Channel?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -26,6 +27,16 @@ struct ChannelBrowserView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
if session.permissions.isAdmin {
|
||||
Button {
|
||||
editChannel = ch
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Channels")
|
||||
@@ -44,6 +55,9 @@ struct ChannelBrowserView: View {
|
||||
.sheet(isPresented: $showCreateChannel) {
|
||||
ChannelEditView(channelId: nil, session: session)
|
||||
}
|
||||
.sheet(item: $editChannel) { ch in
|
||||
ChannelEditView(channelId: ch.id, session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct ChannelEditView: View {
|
||||
let channelId: UInt32?
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// General
|
||||
@State private var name = ""
|
||||
@State private var topic = ""
|
||||
@State private var parentId: UInt32 = 0
|
||||
@State private var passwordProtected = false
|
||||
@State private var password = ""
|
||||
@State private var maxUsers = "0"
|
||||
@State private var sortOrder = "0"
|
||||
|
||||
// Audio (Opus) — populated from the channel's current config when editing.
|
||||
@State private var stereo = false
|
||||
@State private var bitrate = "64000"
|
||||
@State private var sampleRate = "48000"
|
||||
@State private var frameMs: UInt32 = 20
|
||||
@State private var application: UInt32 = 0
|
||||
@State private var packetLoss = "5"
|
||||
@State private var complexity = 10
|
||||
@State private var fec = true
|
||||
@State private var dtx = false
|
||||
@State private var dred = false
|
||||
|
||||
private var isEditing: Bool { channelId != nil }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -17,9 +38,57 @@ struct ChannelEditView: View {
|
||||
.accessibilityLabel("Channel name")
|
||||
TextField("Topic (optional)", text: $topic)
|
||||
.accessibilityLabel("Channel topic, optional")
|
||||
Picker("Parent", selection: $parentId) {
|
||||
Text("(root)").tag(UInt32(0))
|
||||
ForEach(parentOptions) { ch in
|
||||
Text(ch.name).tag(ch.id)
|
||||
}
|
||||
}
|
||||
.navigationTitle(channelId == nil ? "New Channel" : "Edit Channel")
|
||||
.accessibilityLabel("Parent channel")
|
||||
Toggle("Password protected", isOn: $passwordProtected)
|
||||
if passwordProtected {
|
||||
SecureField("Password (blank keeps existing)", text: $password)
|
||||
.accessibilityLabel("Channel password")
|
||||
}
|
||||
TextField("Max users (0 = unlimited)", text: $maxUsers)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Maximum users, zero means unlimited")
|
||||
TextField("Sort order", text: $sortOrder)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Sort order")
|
||||
}
|
||||
|
||||
Section("Audio (Opus)") {
|
||||
Toggle("Stereo", isOn: $stereo)
|
||||
TextField("Bitrate (bps)", text: $bitrate)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Bitrate in bits per second")
|
||||
TextField("Sample rate (Hz)", text: $sampleRate)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Sample rate in Hz")
|
||||
Picker("Frame", selection: $frameMs) {
|
||||
ForEach([UInt32(10), 20, 40, 60], id: \.self) { ms in
|
||||
Text("\(ms) ms").tag(ms)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Opus frame duration")
|
||||
Picker("Application", selection: $application) {
|
||||
Text("VoIP").tag(UInt32(0))
|
||||
Text("Audio").tag(UInt32(1))
|
||||
Text("Low delay").tag(UInt32(2))
|
||||
}
|
||||
.accessibilityLabel("Opus application profile")
|
||||
TextField("Expected packet loss %", text: $packetLoss)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Expected packet loss percent, 0 to 100")
|
||||
Stepper("Complexity: \(complexity)", value: $complexity, in: 0...10)
|
||||
.accessibilityLabel("Opus complexity, 0 to 10")
|
||||
Toggle("FEC (forward error correction)", isOn: $fec)
|
||||
Toggle("DTX (discontinuous transmission)", isOn: $dtx)
|
||||
Toggle("DRED (deep redundancy)", isOn: $dred)
|
||||
}
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Channel" : "New Channel")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
@@ -27,12 +96,78 @@ struct ChannelEditView: View {
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
session.createChannel(name: name, topic: topic)
|
||||
save()
|
||||
dismiss()
|
||||
}
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: loadIfEditing)
|
||||
}
|
||||
}
|
||||
|
||||
/// Channels offered as a parent. Excludes the channel being edited so it can't parent itself.
|
||||
private var parentOptions: [Channel] {
|
||||
session.channels
|
||||
.filter { $0.id != channelId }
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
private func loadIfEditing() {
|
||||
guard let id = channelId,
|
||||
let ch = session.channels.first(where: { $0.id == id }) else { return }
|
||||
name = ch.name
|
||||
topic = ch.topic
|
||||
parentId = ch.parentId
|
||||
passwordProtected = ch.passwordProtected
|
||||
maxUsers = "\(ch.maxUsers)"
|
||||
sortOrder = "\(ch.sortOrder)"
|
||||
stereo = ch.audio.stereo
|
||||
bitrate = "\(ch.audio.bitrateBps)"
|
||||
sampleRate = "\(ch.audio.sampleRate)"
|
||||
frameMs = ch.audio.frameMs
|
||||
application = ch.audio.application
|
||||
packetLoss = "\(ch.audio.expectedPacketLoss)"
|
||||
complexity = Int(ch.audio.complexity)
|
||||
fec = ch.audio.fec
|
||||
dtx = ch.audio.dtx
|
||||
dred = ch.audio.dred
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedName.isEmpty else { return }
|
||||
|
||||
let audio = AudioConfig(
|
||||
stereo: stereo,
|
||||
sampleRate: UInt32(sampleRate) ?? 48000,
|
||||
bitrateBps: UInt32(bitrate) ?? 64000,
|
||||
frameMs: frameMs,
|
||||
application: application,
|
||||
fec: fec,
|
||||
expectedPacketLoss: min(UInt32(packetLoss) ?? 5, 100),
|
||||
dtx: dtx,
|
||||
complexity: UInt32(complexity),
|
||||
dred: dred
|
||||
)
|
||||
|
||||
let pw: String? = passwordProtected ? (password.isEmpty ? nil : password) : nil
|
||||
let info = ChannelEdit(
|
||||
id: channelId ?? 0,
|
||||
parentId: parentId,
|
||||
name: trimmedName,
|
||||
topic: topic,
|
||||
passwordProtected: passwordProtected,
|
||||
password: pw,
|
||||
maxUsers: UInt32(maxUsers) ?? 0,
|
||||
sortOrder: UInt32(sortOrder) ?? 0,
|
||||
audio: audio
|
||||
)
|
||||
|
||||
if isEditing {
|
||||
session.editChannel(info)
|
||||
} else {
|
||||
session.createChannel(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ struct ChannelNode: Identifiable {
|
||||
struct ChannelTreeView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var showCreateChannel = false
|
||||
@State private var editChannel: Channel?
|
||||
@State private var channelPassword = ""
|
||||
@State private var passwordChannelId: UInt32?
|
||||
|
||||
@@ -34,6 +35,16 @@ struct ChannelTreeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
if session.permissions.isAdmin {
|
||||
Button {
|
||||
editChannel = node.channel
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.toolbar {
|
||||
@@ -59,6 +70,9 @@ struct ChannelTreeView: View {
|
||||
.sheet(isPresented: $showCreateChannel) {
|
||||
ChannelEditView(channelId: nil, session: session)
|
||||
}
|
||||
.sheet(item: $editChannel) { ch in
|
||||
ChannelEditView(channelId: ch.id, session: session)
|
||||
}
|
||||
.alert("Channel Password", isPresented: Binding(
|
||||
get: { passwordChannelId != nil },
|
||||
set: { if !$0 { passwordChannelId = nil; channelPassword = "" } }
|
||||
|
||||
@@ -91,7 +91,7 @@ struct ChatView: View {
|
||||
private func sendMessage() {
|
||||
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
session.sendText(text, scope: .channel)
|
||||
session.sendText(text, scope: .channel, targetId: session.currentChannelId)
|
||||
composeText = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ struct PerUserTuningView: View {
|
||||
Section("Volume") {
|
||||
HStack {
|
||||
Text("Gain")
|
||||
Slider(value: $gain, in: 0...2, step: 0.05) { _ in
|
||||
Slider(value: $gain, in: 0...4, step: 0.05) { _ in
|
||||
applyToAllStreams()
|
||||
}
|
||||
.accessibilityLabel("Volume gain for \(user.nickname)")
|
||||
@@ -48,7 +48,6 @@ struct PerUserTuningView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Load from first stream if available
|
||||
let streams = streamsForUser
|
||||
if let first = streams.first {
|
||||
let (_, state) = session.client.getRemoteStream(userId: user.id, streamId: first.id)
|
||||
|
||||
@@ -8,6 +8,14 @@ struct SettingsView: View {
|
||||
@StateObject private var router = IOSAudioRouter.shared
|
||||
@State private var showAdvanced = false
|
||||
|
||||
// Notification feedback prefs — keys shared with VoiceCatCore's FeedbackSettings, so the
|
||||
// EventFeedback player reads the same values these toggles write.
|
||||
@AppStorage("feedback.sounds") private var soundsEnabled = true
|
||||
@AppStorage("feedback.speech") private var speechEnabled = false
|
||||
@AppStorage("feedback.volume") private var soundsVolume = 1.0
|
||||
@AppStorage("feedback.selfTalk") private var selfTalkEnabled = false
|
||||
@AppStorage("feedback.ptt") private var pttSoundEnabled = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
@@ -23,11 +31,28 @@ struct SettingsView: View {
|
||||
}
|
||||
.accessibilityLabel("Audio preset")
|
||||
|
||||
if !router.hasBluetoothDevice && !router.hasWiredHeadset {
|
||||
Text("Connect Bluetooth headphones or a wired headset for more presets.")
|
||||
Toggle("Speaker output", isOn: Binding(
|
||||
get: { router.forceSpeaker },
|
||||
set: { router.setForceSpeaker($0) }
|
||||
))
|
||||
.accessibilityLabel("Speaker output")
|
||||
.accessibilityHint("Routes audio to the speaker instead of the earpiece when no headphones are connected.")
|
||||
|
||||
// Surface the voice-processing state. On Voice Chat the native iOS
|
||||
// Voice-Processing unit (VPIO) does echo cancellation, noise suppression and
|
||||
// automatic gain control; the stereo / mono-mic / A2DP configs can't use it.
|
||||
if router.currentConfigUsesVoiceProcessing {
|
||||
Label("Echo cancellation & noise suppression on (iOS voice processing)",
|
||||
systemImage: "waveform.badge.mic")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("No external audio device connected")
|
||||
.accessibilityLabel("Echo cancellation and noise suppression are on")
|
||||
} else {
|
||||
Label("No echo cancellation in this configuration (stereo / A2DP / off)",
|
||||
systemImage: "waveform.slash")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("Echo cancellation is off in this configuration")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +120,27 @@ struct SettingsView: View {
|
||||
}
|
||||
.accessibilityLabel("Microphone processing mode")
|
||||
|
||||
// Voice-processing (VPIO) controls — only meaningful on a VPIO-capable
|
||||
// config (mono + standard + non-A2DP). iOS bundles echo cancellation and
|
||||
// noise suppression into one master switch (no per-stage toggle); AGC is
|
||||
// the one sub-stage it lets us control independently.
|
||||
if router.voiceProcessingAvailable {
|
||||
Toggle("Voice Processing (AEC + noise suppression)", isOn: Binding(
|
||||
get: { router.voiceProcessingEnabled },
|
||||
set: { router.setVoiceProcessingEnabled($0) }
|
||||
))
|
||||
.accessibilityLabel("Voice processing")
|
||||
.accessibilityHint("Echo cancellation and noise suppression, bundled together by iOS.")
|
||||
|
||||
if router.voiceProcessingEnabled {
|
||||
Toggle("Automatic Gain Control", isOn: Binding(
|
||||
get: { router.agcEnabled },
|
||||
set: { router.setAgcEnabled($0) }
|
||||
))
|
||||
.accessibilityLabel("Automatic gain control")
|
||||
}
|
||||
}
|
||||
|
||||
if router.showsRawModeSpeakerWarning {
|
||||
Label(
|
||||
"Raw mode on speaker — echo risk (no AEC)",
|
||||
@@ -191,6 +237,48 @@ struct SettingsView: View {
|
||||
.accessibilityLabel("Voice activation threshold")
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Mic Volume: \(Int((session.voiceState.inputGain * 100).rounded()))%")
|
||||
.font(.caption)
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(session.voiceState.inputGain) },
|
||||
set: { session.setInputGain(Float($0)) }
|
||||
),
|
||||
in: 0...4, step: 0.05
|
||||
)
|
||||
.accessibilityLabel("Microphone volume")
|
||||
.accessibilityValue("\(Int((session.voiceState.inputGain * 100).rounded())) percent")
|
||||
}
|
||||
|
||||
Toggle("Noise Reduction (RNNoise)", isOn: Binding(
|
||||
get: { session.voiceState.inputNoiseReduction },
|
||||
set: { session.setInputNoiseReduction($0) }
|
||||
))
|
||||
.accessibilityLabel("Microphone noise reduction")
|
||||
.accessibilityHint("Denoises your microphone signal for everyone listening.")
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
Section("Notifications") {
|
||||
Toggle("Event sounds", isOn: $soundsEnabled)
|
||||
.accessibilityLabel("Play event sounds")
|
||||
if soundsEnabled {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sound volume")
|
||||
.font(.caption)
|
||||
Slider(value: $soundsVolume, in: 0...1)
|
||||
.accessibilityLabel("Sound volume")
|
||||
}
|
||||
}
|
||||
Toggle("Speak events (text-to-speech)", isOn: $speechEnabled)
|
||||
.accessibilityLabel("Speak events")
|
||||
.accessibilityHint("Announces joins and leaves and reads message text aloud.")
|
||||
Toggle("Your own voice-activity sounds", isOn: $selfTalkEnabled)
|
||||
.accessibilityLabel("Voice activity sounds")
|
||||
Toggle("Push-to-talk cue", isOn: $pttSoundEnabled)
|
||||
.accessibilityLabel("Push to talk cue")
|
||||
}
|
||||
|
||||
// MARK: - Admin
|
||||
@@ -206,7 +294,7 @@ struct SettingsView: View {
|
||||
// MARK: - Server
|
||||
Section("Server") {
|
||||
Button(role: .destructive) {
|
||||
session.stopMicStream()
|
||||
session.leaveVoice()
|
||||
appState.disconnect()
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "phone.down")
|
||||
|
||||
@@ -18,6 +18,10 @@ struct UserRow: View {
|
||||
var body: some View {
|
||||
UserRowView(user: user, isSelf: user.id == session.selfUserId)
|
||||
.contextMenu { contextMenu }
|
||||
// The context menu is long-press only, which VoiceOver doesn't surface — mirror the
|
||||
// same buttons as accessibility actions so VoiceOver users can reach per-user tuning
|
||||
// (and the admin actions) via the actions rotor on the focused row.
|
||||
.accessibilityActions { contextMenu }
|
||||
.sheet(item: $activeSheet) { sheet in
|
||||
switch sheet {
|
||||
case .tuning: PerUserTuningView(user: user, session: session)
|
||||
|
||||
@@ -4,6 +4,7 @@ import ReplayKit
|
||||
|
||||
struct VoiceControlsView: View {
|
||||
@Bindable var session: SessionState
|
||||
@StateObject private var router = IOSAudioRouter.shared
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 20) {
|
||||
@@ -13,9 +14,9 @@ struct VoiceControlsView: View {
|
||||
} else {
|
||||
Button {
|
||||
if session.voiceState.micActive {
|
||||
session.stopMicStream()
|
||||
session.leaveVoice()
|
||||
} else {
|
||||
session.startMicStream()
|
||||
session.joinVoice()
|
||||
}
|
||||
} label: {
|
||||
Text(session.voiceState.micActive ? "Leave Voice" : "Join Voice")
|
||||
@@ -27,7 +28,7 @@ struct VoiceControlsView: View {
|
||||
.foregroundStyle(session.voiceState.micActive ? .green : .accentColor)
|
||||
}
|
||||
.disabled(session.currentChannelId == 0)
|
||||
.accessibilityLabel(session.voiceState.micActive ? "Leave Voice — stop sending microphone audio" : "Join Voice — start sending microphone audio")
|
||||
.accessibilityLabel(session.voiceState.micActive ? "Leave Voice" : "Join Voice")
|
||||
}
|
||||
|
||||
// Level meter
|
||||
@@ -37,6 +38,19 @@ struct VoiceControlsView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// Speaker output toggle — force the built-in speaker instead of the earpiece when
|
||||
// no headphones/BT are connected. Mirrors the persisted Settings ▸ Audio toggle.
|
||||
Button {
|
||||
router.setForceSpeaker(!router.forceSpeaker)
|
||||
} label: {
|
||||
Image(systemName: router.forceSpeaker ? "speaker.wave.2.fill" : "speaker.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(router.forceSpeaker ? Color.accentColor : .primary)
|
||||
}
|
||||
.accessibilityLabel(router.forceSpeaker
|
||||
? "Speaker on — turn off to use the earpiece"
|
||||
: "Speaker off — turn on for speakerphone")
|
||||
|
||||
// Self mute (disabled when not in voice)
|
||||
Button {
|
||||
session.setMute(!session.voiceState.selfMuted, deafened: session.voiceState.selfDeafened)
|
||||
@@ -67,7 +81,7 @@ struct VoiceControlsView: View {
|
||||
|
||||
// Disconnect
|
||||
Button(role: .destructive) {
|
||||
session.stopMicStream()
|
||||
session.leaveVoice()
|
||||
session.client.disconnect()
|
||||
} label: {
|
||||
Image(systemName: "phone.down.fill")
|
||||
@@ -101,13 +115,13 @@ private struct PTTButton: View {
|
||||
.updating($isPressing) { _, state, _ in state = true }
|
||||
.onChanged { _ in
|
||||
if !isPressing { return }
|
||||
if !session.voiceState.micActive { session.startMicStream() }
|
||||
if !session.voiceState.micActive { session.joinVoice() }
|
||||
session.setPushToTalk(true)
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
}
|
||||
.onEnded { _ in
|
||||
session.setPushToTalk(false)
|
||||
session.stopMicStream()
|
||||
session.leaveVoice()
|
||||
}
|
||||
)
|
||||
.accessibilityLabel("Push to talk, hold to transmit")
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.cat.voice.VoiceCat</string>
|
||||
<string>group.me.iamtalon.voicecat</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
AAAA00000000000000000048 /* UserPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000047 /* UserPickerSheet.swift */; };
|
||||
AAAA0000000000000000004A /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000049 /* SettingsWindowController.swift */; };
|
||||
AAAA0000000000000000004C /* ScreenAudioCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000004B /* ScreenAudioCapture.swift */; };
|
||||
AAAA00000000000000000051 /* InputDeviceCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000050 /* InputDeviceCapture.swift */; };
|
||||
AAAA0000000000000000004E /* ScreenSharePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000004F /* ScreenSharePickerSheet.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -44,6 +46,7 @@
|
||||
AAAA00000000000000000019 /* ConnectWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectWindowController.swift; sourceTree = "<group>"; };
|
||||
AAAA0000000000000000001A /* MainWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = "<group>"; };
|
||||
AAAA0000000000000000004B /* ScreenAudioCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenAudioCapture.swift; sourceTree = "<group>"; };
|
||||
AAAA00000000000000000050 /* InputDeviceCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputDeviceCapture.swift; sourceTree = "<group>"; };
|
||||
AAAA0000000000000000001B /* AddServerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerSheet.swift; sourceTree = "<group>"; };
|
||||
AAAA0000000000000000001C /* ServerIdentitySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerIdentitySheet.swift; sourceTree = "<group>"; };
|
||||
AAAA0000000000000000001D /* PasswordPromptSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptSheet.swift; sourceTree = "<group>"; };
|
||||
@@ -57,6 +60,7 @@
|
||||
AAAA00000000000000000044 /* PttKeyCaptureSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PttKeyCaptureSheet.swift; sourceTree = "<group>"; };
|
||||
AAAA00000000000000000045 /* PrivateMessageWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateMessageWindowController.swift; sourceTree = "<group>"; };
|
||||
AAAA00000000000000000047 /* UserPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPickerSheet.swift; sourceTree = "<group>"; };
|
||||
AAAA0000000000000000004F /* ScreenSharePickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSharePickerSheet.swift; sourceTree = "<group>"; };
|
||||
AAAA00000000000000000049 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
|
||||
AAAA00000000000000000025 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -102,6 +106,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AAAA0000000000000000004B /* ScreenAudioCapture.swift */,
|
||||
AAAA00000000000000000050 /* InputDeviceCapture.swift */,
|
||||
);
|
||||
path = Audio;
|
||||
sourceTree = "<group>";
|
||||
@@ -141,6 +146,7 @@
|
||||
AAAA00000000000000000024 /* PermissionsSheet.swift */,
|
||||
AAAA00000000000000000044 /* PttKeyCaptureSheet.swift */,
|
||||
AAAA00000000000000000047 /* UserPickerSheet.swift */,
|
||||
AAAA0000000000000000004F /* ScreenSharePickerSheet.swift */,
|
||||
);
|
||||
path = Sheets;
|
||||
sourceTree = "<group>";
|
||||
@@ -241,8 +247,10 @@
|
||||
AAAA00000000000000000043 /* PttKeyCaptureSheet.swift in Sources */,
|
||||
AAAA00000000000000000046 /* PrivateMessageWindowController.swift in Sources */,
|
||||
AAAA00000000000000000048 /* UserPickerSheet.swift in Sources */,
|
||||
AAAA0000000000000000004E /* ScreenSharePickerSheet.swift in Sources */,
|
||||
AAAA0000000000000000004A /* SettingsWindowController.swift in Sources */,
|
||||
AAAA0000000000000000004C /* ScreenAudioCapture.swift in Sources */,
|
||||
AAAA00000000000000000051 /* InputDeviceCapture.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import AVFoundation
|
||||
import CoreAudio
|
||||
|
||||
// InputDeviceCapture — captures a single hardware INPUT device (mic / line-in / aux) on macOS and
|
||||
// emits 20 ms (960 samples/channel @ 48 kHz, interleaved int16) frames for the aux outgoing stream.
|
||||
//
|
||||
// The input-device analogue of ScreenAudioCapture (which captures system audio via
|
||||
// ScreenCaptureKit). The core already owns ONE capture device (the mic) and can't open a second
|
||||
// arbitrary input, so for the aux stream the client captures the chosen device here and feeds PCM
|
||||
// into the core via `vc_stream_feed_pcm` — the same external-feed pipeline screen audio uses.
|
||||
//
|
||||
// Device selection: an AVAudioEngine's input node wraps an AUHAL audio unit; setting
|
||||
// kAudioOutputUnitProperty_CurrentDevice on it pins capture to a specific Core Audio device. We
|
||||
// pass the device's *UID* (stable across reboots/replugs, unlike AudioDeviceID) and resolve it at
|
||||
// start. The tap delivers Float32; AVAudioConverter resamples/quantises to 48 kHz int16.
|
||||
final class InputDeviceCapture {
|
||||
|
||||
/// Receives a full 20 ms frame: (interleaved int16 PCM, samplesPerChannel = 960, channels).
|
||||
typealias PcmHandler = (UnsafePointer<Int16>, Int, UInt32) -> Void
|
||||
|
||||
enum CaptureError: Error { case deviceNotFound, engineStartFailed(OSStatus) }
|
||||
|
||||
private static let frameSamplesPerChannel = 960 // 20 ms @ 48 kHz
|
||||
|
||||
private let deviceUID: String? // nil = system default input device
|
||||
private let onPcm: PcmHandler
|
||||
private let engine = AVAudioEngine()
|
||||
private var converter: AVAudioConverter?
|
||||
private var outFormat: AVAudioFormat?
|
||||
private var channels = 1
|
||||
|
||||
/// Interleaved int16 carry-over between tap callbacks (the tap buffer doesn't align to 20 ms),
|
||||
/// drained in whole `frameSamplesPerChannel * channels` chunks. Only touched on the tap queue.
|
||||
private var pending: [Int16] = []
|
||||
|
||||
init(deviceUID: String?, onPcm: @escaping PcmHandler) {
|
||||
self.deviceUID = deviceUID
|
||||
self.onPcm = onPcm
|
||||
}
|
||||
|
||||
/// Begin capture. Throws if the device can't be resolved or the engine fails to start.
|
||||
func start() throws {
|
||||
let input = engine.inputNode
|
||||
|
||||
// Pin the engine's AUHAL to the chosen device (skip for default — the engine already uses
|
||||
// the system default input). Must happen before reading inputFormat, which changes with
|
||||
// the selected device.
|
||||
if let deviceUID, let devId = Self.deviceID(forUID: deviceUID) {
|
||||
var dev = devId
|
||||
let status = AudioUnitSetProperty(input.audioUnit!,
|
||||
kAudioOutputUnitProperty_CurrentDevice,
|
||||
kAudioUnitScope_Global, 0,
|
||||
&dev, UInt32(MemoryLayout<AudioDeviceID>.size))
|
||||
if status != noErr { throw CaptureError.engineStartFailed(status) }
|
||||
} else if deviceUID != nil {
|
||||
throw CaptureError.deviceNotFound
|
||||
}
|
||||
|
||||
let inFormat = input.inputFormat(forBus: 0)
|
||||
channels = max(1, min(2, Int(inFormat.channelCount)))
|
||||
guard let out = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000,
|
||||
channels: AVAudioChannelCount(channels), interleaved: true)
|
||||
else { throw CaptureError.deviceNotFound }
|
||||
outFormat = out
|
||||
converter = AVAudioConverter(from: inFormat, to: out)
|
||||
|
||||
input.installTap(onBus: 0, bufferSize: 960, format: inFormat) { [weak self] buf, _ in
|
||||
self?.process(buf)
|
||||
}
|
||||
engine.prepare()
|
||||
do { try engine.start() }
|
||||
catch { throw CaptureError.engineStartFailed(-1) }
|
||||
}
|
||||
|
||||
/// Stop capture and tear down the engine. Safe to call multiple times.
|
||||
func stop() {
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
if engine.isRunning { engine.stop() }
|
||||
converter = nil
|
||||
pending.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
// MARK: - Conversion (called on the tap's realtime thread)
|
||||
|
||||
private func process(_ inBuf: AVAudioPCMBuffer) {
|
||||
guard let converter, let outFormat else { return }
|
||||
|
||||
// Output capacity must cover up-sampling (e.g. 44.1 → 48 kHz) plus slack.
|
||||
let ratio = outFormat.sampleRate / inBuf.format.sampleRate
|
||||
let cap = AVAudioFrameCount(Double(inBuf.frameLength) * ratio + 32)
|
||||
guard cap > 0, let outBuf = AVAudioPCMBuffer(pcmFormat: outFormat, frameCapacity: cap)
|
||||
else { return }
|
||||
|
||||
var fed = false
|
||||
var err: NSError?
|
||||
let status = converter.convert(to: outBuf, error: &err) { _, outStatus in
|
||||
if fed { outStatus.pointee = .noDataNow; return nil }
|
||||
fed = true
|
||||
outStatus.pointee = .haveData
|
||||
return inBuf
|
||||
}
|
||||
guard status != .error, outBuf.frameLength > 0,
|
||||
let ch = outBuf.int16ChannelData else { return }
|
||||
|
||||
// Interleaved int16: all channels live in the first buffer (ch[0]).
|
||||
let n = Int(outBuf.frameLength) * channels
|
||||
pending.append(contentsOf: UnsafeBufferPointer(start: ch[0], count: n))
|
||||
emit()
|
||||
}
|
||||
|
||||
/// Fire `onPcm` for every whole 20 ms frame accumulated.
|
||||
private func emit() {
|
||||
let full = Self.frameSamplesPerChannel * channels
|
||||
while pending.count >= full {
|
||||
pending.withUnsafeBufferPointer { buf in
|
||||
onPcm(buf.baseAddress!, Self.frameSamplesPerChannel, UInt32(channels))
|
||||
}
|
||||
pending.removeFirst(full)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UID → AudioDeviceID resolution
|
||||
|
||||
private static func deviceID(forUID uid: String) -> AudioDeviceID? {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyTranslateUIDToDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var deviceID = AudioDeviceID(0)
|
||||
var cfUID = uid as CFString
|
||||
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||
let status = withUnsafeMutablePointer(to: &cfUID) { uidPtr -> OSStatus in
|
||||
AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr,
|
||||
UInt32(MemoryLayout<CFString>.size), uidPtr,
|
||||
&size, &deviceID)
|
||||
}
|
||||
return (status == noErr && deviceID != 0) ? deviceID : nil
|
||||
}
|
||||
}
|
||||
|
||||
// InputDeviceInfo / InputDeviceEnumerator — Core Audio input-device enumeration for the aux-stream
|
||||
// picker. Separate from the core's vc_list_devices (whose ids are miniaudio-opaque and can't be
|
||||
// passed to Core Audio); the aux device is client-captured, so the picker uses device UIDs.
|
||||
struct InputDeviceInfo: Equatable {
|
||||
let uid: String // stable across reboots/replugs — what we persist
|
||||
let name: String
|
||||
let isDefault: Bool
|
||||
}
|
||||
|
||||
enum InputDeviceEnumerator {
|
||||
|
||||
/// All Core Audio devices that expose at least one input channel.
|
||||
static func list() -> [InputDeviceInfo] {
|
||||
let defaultUID = defaultInputUID()
|
||||
var result: [InputDeviceInfo] = []
|
||||
|
||||
for devID in allDeviceIDs() {
|
||||
guard inputChannelCount(devID) > 0 else { continue }
|
||||
guard let uid = stringProperty(devID, kAudioDevicePropertyDeviceUID) else { continue }
|
||||
let name = stringProperty(devID, kAudioObjectPropertyName)
|
||||
?? stringProperty(devID, kAudioDevicePropertyDeviceNameCFString)
|
||||
?? "Unknown input device"
|
||||
result.append(InputDeviceInfo(uid: uid, name: name, isDefault: uid == defaultUID))
|
||||
}
|
||||
return result.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
// MARK: - Core Audio helpers
|
||||
|
||||
private static func allDeviceIDs() -> [AudioDeviceID] {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var size = UInt32(0)
|
||||
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil,
|
||||
&size) == noErr, size > 0 else { return [] }
|
||||
let count = Int(size) / MemoryLayout<AudioDeviceID>.size
|
||||
var ids = [AudioDeviceID](repeating: 0, count: count)
|
||||
let status = AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0,
|
||||
nil, &size, &ids)
|
||||
return status == noErr ? ids : []
|
||||
}
|
||||
|
||||
private static func inputChannelCount(_ devID: AudioDeviceID) -> Int {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||
mScope: kAudioObjectPropertyScopeInput,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var size = UInt32(0)
|
||||
guard AudioObjectGetPropertyDataSize(devID, &addr, 0, nil, &size) == noErr, size > 0
|
||||
else { return 0 }
|
||||
let bufList = UnsafeMutableRawPointer.allocate(byteCount: Int(size),
|
||||
alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||
defer { bufList.deallocate() }
|
||||
guard AudioObjectGetPropertyData(devID, &addr, 0, nil, &size, bufList) == noErr
|
||||
else { return 0 }
|
||||
let abl = UnsafeMutableAudioBufferListPointer(
|
||||
bufList.assumingMemoryBound(to: AudioBufferList.self))
|
||||
return abl.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||
}
|
||||
|
||||
private static func defaultInputUID() -> String? {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var devID = AudioDeviceID(0)
|
||||
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil,
|
||||
&size, &devID) == noErr, devID != 0 else { return nil }
|
||||
return stringProperty(devID, kAudioDevicePropertyDeviceUID)
|
||||
}
|
||||
|
||||
private static func stringProperty(_ devID: AudioDeviceID,
|
||||
_ selector: AudioObjectPropertySelector) -> String? {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: selector,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var cf: CFString? = nil
|
||||
var size = UInt32(MemoryLayout<CFString?>.size)
|
||||
let status = withUnsafeMutablePointer(to: &cf) { ptr in
|
||||
AudioObjectGetPropertyData(devID, &addr, 0, nil, &size, ptr)
|
||||
}
|
||||
guard status == noErr else { return nil }
|
||||
return cf as String?
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
import AVFoundation
|
||||
import ScreenCaptureKit
|
||||
|
||||
// ScreenAudioCapture — macOS system/desktop audio capture for the SCREEN_AUDIO stream.
|
||||
//
|
||||
// The macOS analog of the Windows WASAPI loopback path (docs/voice.md §9). ScreenCaptureKit
|
||||
// (macOS 13+) captures whatever the system is playing; we convert each audio CMSampleBuffer
|
||||
// (Float32) → int16 interleaved and push 20 ms frames (960 samples/channel @ 48 kHz) into the
|
||||
// core via `vc_stream_feed_pcm` (exposed as `VoiceCatClient.feedPcm`). The core then runs the
|
||||
// same Opus-encode → media-AEAD → UDP path as any other stream — only the *source* is
|
||||
// platform-specific (architecture.md §4).
|
||||
//
|
||||
// Audio-only: we request a 2×2 video plane at 1 fps purely because SCStream needs a video
|
||||
// configuration, and we never add a `.screen` output — only `.audio`. `excludesCurrentProcess
|
||||
// Audio` prevents the self-echo loop of re-capturing our own incoming voice mix.
|
||||
//
|
||||
// `feedPcm` is thread-safe (any thread), so we forward straight from the sample-handler queue.
|
||||
// ScreenCaptureKit filters audio by application bundle identifier.
|
||||
enum ScreenAudioScope: Equatable {
|
||||
case entireDesktop
|
||||
case onlyApps([String])
|
||||
case allExcept([String])
|
||||
}
|
||||
|
||||
struct ScreenAudioSelection: Equatable {
|
||||
var scope: ScreenAudioScope = .entireDesktop
|
||||
/// Drop the macOS screen-reader (VoiceOver) speech from the shared mix. Meaningful for
|
||||
/// `.entireDesktop`/`.allExcept`; for `.onlyApps` the screen reader is already excluded.
|
||||
var excludeScreenReader: Bool = false
|
||||
|
||||
static let `default` = ScreenAudioSelection()
|
||||
}
|
||||
|
||||
// SCStream requires a minimal video configuration even for audio-only capture. Only its audio
|
||||
// output is registered, and current-process audio is excluded to prevent feedback.
|
||||
final class ScreenAudioCapture: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
|
||||
/// Receives a full 20 ms frame: (interleaved int16 PCM, samplesPerChannel = 960, channels).
|
||||
@@ -26,16 +30,27 @@ final class ScreenAudioCapture: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
|
||||
private let onPcm: PcmHandler
|
||||
private let channels: Int // 1 (mono) or 2 (stereo interleaved), matches the stream's mode
|
||||
private let selection: ScreenAudioSelection
|
||||
private let sampleQueue = DispatchQueue(label: "cat.voice.screenaudio.samples")
|
||||
private var stream: SCStream?
|
||||
|
||||
/// Bundle IDs whose audio carries the macOS screen-reader speech. VoiceOver itself plus the
|
||||
/// speech-synthesis daemon that actually renders the spoken audio — the speech is usually
|
||||
/// emitted by the daemon, not the VoiceOver app, so we exclude whichever are running.
|
||||
static let screenReaderBundleIDs: Set<String> = [
|
||||
"com.apple.VoiceOver",
|
||||
"com.apple.VoiceOver4",
|
||||
"com.apple.speech.speechsynthesisd",
|
||||
]
|
||||
|
||||
/// Interleaved int16 carry-over between callbacks (ScreenCaptureKit buffers don't align to
|
||||
/// 20 ms), drained in whole `frameSamplesPerChannel * channels` chunks. Only touched on
|
||||
/// `sampleQueue`.
|
||||
private var pending: [Int16] = []
|
||||
|
||||
init(channels: UInt32, onPcm: @escaping PcmHandler) {
|
||||
init(channels: UInt32, selection: ScreenAudioSelection, onPcm: @escaping PcmHandler) {
|
||||
self.channels = max(1, min(2, Int(channels)))
|
||||
self.selection = selection
|
||||
self.onPcm = onPcm
|
||||
super.init()
|
||||
}
|
||||
@@ -46,7 +61,8 @@ final class ScreenAudioCapture: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
let content = try await SCShareableContent.current
|
||||
guard let display = content.displays.first else { throw CaptureError.noDisplay }
|
||||
|
||||
let filter = SCContentFilter(display: display, excludingWindows: [])
|
||||
let filter = Self.makeFilter(selection: selection, display: display,
|
||||
apps: content.applications)
|
||||
|
||||
let config = SCStreamConfiguration()
|
||||
config.capturesAudio = true
|
||||
@@ -65,6 +81,39 @@ final class ScreenAudioCapture: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
self.stream = stream
|
||||
}
|
||||
|
||||
/// Turn a `ScreenAudioSelection` into an `SCContentFilter` against the running apps.
|
||||
/// ScreenCaptureKit filters audio per application, so we map bundle IDs → SCRunningApplication.
|
||||
private static func makeFilter(selection: ScreenAudioSelection, display: SCDisplay,
|
||||
apps: [SCRunningApplication]) -> SCContentFilter {
|
||||
func appsMatching(_ ids: Set<String>) -> [SCRunningApplication] {
|
||||
apps.filter { ids.contains($0.bundleIdentifier) }
|
||||
}
|
||||
|
||||
switch selection.scope {
|
||||
case .onlyApps(let bundleIDs):
|
||||
// Include-only already excludes everything else (the screen reader included), so the
|
||||
// excludeScreenReader flag is moot in this mode.
|
||||
return SCContentFilter(display: display,
|
||||
including: appsMatching(Set(bundleIDs)),
|
||||
exceptingWindows: [])
|
||||
|
||||
case .allExcept(let bundleIDs):
|
||||
var ids = Set(bundleIDs)
|
||||
if selection.excludeScreenReader { ids.formUnion(screenReaderBundleIDs) }
|
||||
return SCContentFilter(display: display,
|
||||
excludingApplications: appsMatching(ids),
|
||||
exceptingWindows: [])
|
||||
|
||||
case .entireDesktop:
|
||||
if selection.excludeScreenReader {
|
||||
return SCContentFilter(display: display,
|
||||
excludingApplications: appsMatching(screenReaderBundleIDs),
|
||||
exceptingWindows: [])
|
||||
}
|
||||
return SCContentFilter(display: display, excludingWindows: [])
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop capture and release the stream. Safe to call multiple times.
|
||||
func stop() {
|
||||
guard let stream else { return }
|
||||
|
||||
@@ -11,6 +11,9 @@ struct SavedServer: Codable, Identifiable {
|
||||
var port: UInt16
|
||||
var authMode: AuthMode
|
||||
var savedUsername: String?
|
||||
/// Free-form display name used when connecting as a guest. Distinct from the account
|
||||
/// `savedUsername`. Empty/nil falls back to the system full name.
|
||||
var nickname: String?
|
||||
var keychainTag: String?
|
||||
|
||||
var displayString: String {
|
||||
|
||||
@@ -13,6 +13,7 @@ final class AddServerSheet: NSViewController {
|
||||
return f
|
||||
}()
|
||||
private let authPicker = NSPopUpButton()
|
||||
private let nicknameField = NSTextField()
|
||||
private let usernameField = NSTextField()
|
||||
private let passwordField = NSSecureTextField()
|
||||
private let savePwCheckbox = NSButton(checkboxWithTitle: "Save password in Keychain", target: nil, action: nil)
|
||||
@@ -35,6 +36,7 @@ final class AddServerSheet: NSViewController {
|
||||
hostField.stringValue = s.host
|
||||
portField.stringValue = "\(s.port)"
|
||||
authPicker.selectItem(withTitle: s.authMode == .guest ? "Guest" : "Account")
|
||||
nicknameField.stringValue = s.nickname ?? ""
|
||||
usernameField.stringValue = s.savedUsername ?? ""
|
||||
updateAuthVisibility()
|
||||
}
|
||||
@@ -47,6 +49,7 @@ final class AddServerSheet: NSViewController {
|
||||
let hostLabel = NSTextField(labelWithString: "Host:")
|
||||
let portLabel = NSTextField(labelWithString: "Port:")
|
||||
let authLabel = NSTextField(labelWithString: "Auth:")
|
||||
let nickLabel = NSTextField(labelWithString: "Nickname:")
|
||||
let userLabel = NSTextField(labelWithString: "Username:")
|
||||
let pwLabel = NSTextField(labelWithString: "Password:")
|
||||
|
||||
@@ -60,6 +63,9 @@ final class AddServerSheet: NSViewController {
|
||||
authPicker.target = self; authPicker.action = #selector(authChanged)
|
||||
authPicker.setAccessibilityLabel("Authentication mode")
|
||||
|
||||
nicknameField.placeholderString = "leave blank to use your system name"
|
||||
nicknameField.setAccessibilityLabel("Guest nickname (display name)")
|
||||
|
||||
usernameField.placeholderString = "username"
|
||||
usernameField.setAccessibilityLabel("Username")
|
||||
|
||||
@@ -79,6 +85,7 @@ final class AddServerSheet: NSViewController {
|
||||
[hostLabel, hostField],
|
||||
[portLabel, portField],
|
||||
[authLabel, authPicker],
|
||||
[nickLabel, nicknameField],
|
||||
[userLabel, usernameField],
|
||||
[pwLabel, passwordField],
|
||||
[NSView(), savePwCheckbox],
|
||||
@@ -110,6 +117,7 @@ final class AddServerSheet: NSViewController {
|
||||
|
||||
private func updateAuthVisibility() {
|
||||
let isAccount = authPicker.titleOfSelectedItem == "Account"
|
||||
nicknameField.isEnabled = !isAccount
|
||||
usernameField.isEnabled = isAccount
|
||||
passwordField.isEnabled = isAccount
|
||||
savePwCheckbox.isEnabled = isAccount
|
||||
@@ -130,6 +138,8 @@ final class AddServerSheet: NSViewController {
|
||||
server.host = host; server.port = port
|
||||
server.authMode = isGuest ? .guest : .password
|
||||
server.savedUsername = isGuest ? nil : usernameField.stringValue.trimmingCharacters(in: .whitespaces)
|
||||
let nick = nicknameField.stringValue.trimmingCharacters(in: .whitespaces)
|
||||
server.nickname = nick.isEmpty ? nil : nick
|
||||
|
||||
if !isGuest && savePwCheckbox.state == .on {
|
||||
let pw = passwordField.stringValue
|
||||
|
||||
@@ -25,7 +25,18 @@ final class ChannelEditSheet: NSViewController {
|
||||
}()
|
||||
private let fecCheckbox = NSButton(checkboxWithTitle: "FEC", target: nil, action: nil)
|
||||
private let dtxCheckbox = NSButton(checkboxWithTitle: "DTX", target: nil, action: nil)
|
||||
private let dredCheckbox = NSButton(checkboxWithTitle: "DRED", target: nil, action: nil)
|
||||
private let frameMsPicker = NSPopUpButton()
|
||||
private let applicationPicker = NSPopUpButton()
|
||||
private let sampleRateField: NSTextField = {
|
||||
let f = NSTextField(); f.stringValue = "48000"; return f
|
||||
}()
|
||||
private let packetLossField: NSTextField = {
|
||||
let f = NSTextField(); f.stringValue = "5"; return f
|
||||
}()
|
||||
private let complexityField: NSTextField = {
|
||||
let f = NSTextField(); f.stringValue = "10"; return f
|
||||
}()
|
||||
|
||||
init(channels: [Channel], editing: ChannelEdit?) {
|
||||
self.channels = channels
|
||||
@@ -72,14 +83,24 @@ final class ChannelEditSheet: NSViewController {
|
||||
maxUsersField.setAccessibilityLabel("Max users (0 = unlimited)")
|
||||
sortOrderField.setAccessibilityLabel("Sort order")
|
||||
|
||||
for ms in ["20", "40", "60"] { frameMsPicker.addItem(withTitle: "\(ms) ms") }
|
||||
for ms in ["10", "20", "40", "60"] { frameMsPicker.addItem(withTitle: "\(ms) ms") }
|
||||
frameMsPicker.selectItem(withTitle: "20 ms")
|
||||
frameMsPicker.setAccessibilityLabel("Opus frame duration")
|
||||
|
||||
applicationPicker.addItem(withTitle: "VoIP")
|
||||
applicationPicker.addItem(withTitle: "Audio")
|
||||
applicationPicker.addItem(withTitle: "Low delay")
|
||||
applicationPicker.setAccessibilityLabel("Opus application profile")
|
||||
|
||||
fecCheckbox.state = .on
|
||||
stereoCheckbox.setAccessibilityLabel("Stereo audio")
|
||||
bitrateField.setAccessibilityLabel("Bitrate in bits per second")
|
||||
sampleRateField.setAccessibilityLabel("Sample rate in Hz")
|
||||
packetLossField.setAccessibilityLabel("Expected packet loss percent (0 to 100)")
|
||||
complexityField.setAccessibilityLabel("Opus complexity (0 to 10)")
|
||||
fecCheckbox.setAccessibilityLabel("Forward error correction")
|
||||
dtxCheckbox.setAccessibilityLabel("Discontinuous transmission")
|
||||
dredCheckbox.setAccessibilityLabel("Deep redundancy (DRED)")
|
||||
|
||||
let generalGrid = NSGridView(views: [
|
||||
[NSTextField(labelWithString: "Name:"), nameField],
|
||||
@@ -94,9 +115,13 @@ final class ChannelEditSheet: NSViewController {
|
||||
|
||||
let audioGrid = NSGridView(views: [
|
||||
[NSTextField(labelWithString: "Bitrate:"), bitrateField],
|
||||
[NSTextField(labelWithString: "Sample rate:"), sampleRateField],
|
||||
[NSTextField(labelWithString: "Frame:"), frameMsPicker],
|
||||
[NSTextField(labelWithString: "Application:"), applicationPicker],
|
||||
[NSTextField(labelWithString: "Packet loss %:"), packetLossField],
|
||||
[NSTextField(labelWithString: "Complexity:"), complexityField],
|
||||
[stereoCheckbox, fecCheckbox],
|
||||
[dtxCheckbox, NSView()],
|
||||
[dtxCheckbox, dredCheckbox],
|
||||
])
|
||||
audioGrid.rowSpacing = 8; audioGrid.columnSpacing = 8
|
||||
audioGrid.column(at: 0).xPlacement = .trailing
|
||||
@@ -149,8 +174,13 @@ final class ChannelEditSheet: NSViewController {
|
||||
sortOrderField.stringValue = "\(e.sortOrder)"
|
||||
stereoCheckbox.state = e.audio.stereo ? .on : .off
|
||||
bitrateField.stringValue = "\(e.audio.bitrateBps)"
|
||||
sampleRateField.stringValue = "\(e.audio.sampleRate)"
|
||||
packetLossField.stringValue = "\(e.audio.expectedPacketLoss)"
|
||||
complexityField.stringValue = "\(e.audio.complexity)"
|
||||
fecCheckbox.state = e.audio.fec ? .on : .off
|
||||
dtxCheckbox.state = e.audio.dtx ? .on : .off
|
||||
dredCheckbox.state = e.audio.dred ? .on : .off
|
||||
applicationPicker.selectItem(at: Int(min(e.audio.application, 2)))
|
||||
let frameStr = "\(e.audio.frameMs) ms"
|
||||
if let item = frameMsPicker.item(withTitle: frameStr) { frameMsPicker.select(item) }
|
||||
}
|
||||
@@ -167,15 +197,24 @@ final class ChannelEditSheet: NSViewController {
|
||||
let maxUsers = UInt32(maxUsersField.stringValue) ?? 0
|
||||
let sortOrder = UInt32(sortOrderField.stringValue) ?? 0
|
||||
let bitrate = UInt32(bitrateField.stringValue) ?? 64000
|
||||
let sampleRate = UInt32(sampleRateField.stringValue) ?? 48000
|
||||
let packetLoss = min(UInt32(packetLossField.stringValue) ?? 5, 100)
|
||||
let complexity = min(UInt32(complexityField.stringValue) ?? 10, 10)
|
||||
let frameMsStr = frameMsPicker.titleOfSelectedItem?.replacingOccurrences(of: " ms", with: "") ?? "20"
|
||||
let frameMs = UInt32(frameMsStr) ?? 20
|
||||
let application = UInt32(max(applicationPicker.indexOfSelectedItem, 0))
|
||||
|
||||
let audio = AudioConfig(
|
||||
stereo: stereoCheckbox.state == .on,
|
||||
sampleRate: sampleRate,
|
||||
bitrateBps: bitrate,
|
||||
frameMs: frameMs,
|
||||
application: application,
|
||||
fec: fecCheckbox.state == .on,
|
||||
dtx: dtxCheckbox.state == .on
|
||||
expectedPacketLoss: packetLoss,
|
||||
dtx: dtxCheckbox.state == .on,
|
||||
complexity: complexity,
|
||||
dred: dredCheckbox.state == .on
|
||||
)
|
||||
let pwProtected = pwCheckbox.state == .on
|
||||
let pw: String? = pwProtected ? (pwField.stringValue.isEmpty ? nil : pwField.stringValue) : nil
|
||||
|
||||
@@ -49,7 +49,7 @@ final class PerUserTuningSheet: NSViewController {
|
||||
let muted = state?.muted ?? false
|
||||
let nr = state?.noiseReduction ?? false
|
||||
|
||||
let gainSlider = NSSlider(value: Double(gain * 100), minValue: 0, maxValue: 200, target: self, action: #selector(sliderChanged))
|
||||
let gainSlider = NSSlider(value: Double(gain * 100), minValue: 0, maxValue: 400, target: self, action: #selector(sliderChanged))
|
||||
gainSlider.tag = Int(stream.id)
|
||||
gainSlider.numberOfTickMarks = 0
|
||||
gainSlider.setAccessibilityLabel("Volume for \(stream.label): \(Int(gain * 100)) percent")
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import AppKit
|
||||
import ScreenCaptureKit
|
||||
|
||||
// ScreenSharePickerSheet — chooses *what* the SCREEN_AUDIO stream captures before sharing
|
||||
// starts. ScreenCaptureKit filters audio per application (not per window), so the user picks
|
||||
// a mode (everything / only-these / all-except-these) plus a set of apps, and a dedicated
|
||||
// toggle to drop their own screen-reader (VoiceOver) speech from the mix.
|
||||
//
|
||||
// Mirrors the modal-sheet pattern used by the rest of the macOS client (UserPickerSheet,
|
||||
// MoveUserSheet, …): an NSViewController presented via MainWindowController.presentSheet, with
|
||||
// an `onComplete` callback that returns the chosen `ScreenAudioSelection` (or nil on cancel).
|
||||
//
|
||||
// The app list comes from `SCShareableContent.current`, fetched asynchronously — that first
|
||||
// access is also what surfaces the Screen Recording (TCC) prompt, which is why the picker is
|
||||
// the natural place for it to appear, before any capture begins.
|
||||
final class ScreenSharePickerSheet: NSViewController, NSTableViewDataSource, NSTableViewDelegate {
|
||||
|
||||
/// Called with the chosen selection, or `nil` if the user cancelled.
|
||||
var onComplete: ((ScreenAudioSelection?) -> Void)?
|
||||
|
||||
private enum Mode: Int { case everything = 0, only = 1, except = 2 }
|
||||
|
||||
private struct AppEntry { let name: String; let bundleID: String; let icon: NSImage? }
|
||||
|
||||
private let initialSelection: ScreenAudioSelection
|
||||
private var mode: Mode
|
||||
private var excludeScreenReader: Bool
|
||||
private var checked: Set<String> // bundle IDs ticked in the app table
|
||||
|
||||
private var apps: [AppEntry] = []
|
||||
private let tableView = NSTableView()
|
||||
private var modeControl: NSSegmentedControl?
|
||||
private var screenReaderCheckbox: NSButton?
|
||||
private var statusLabel: NSTextField?
|
||||
|
||||
init(selection: ScreenAudioSelection) {
|
||||
self.initialSelection = selection
|
||||
switch selection.scope {
|
||||
case .entireDesktop: mode = .everything; checked = []
|
||||
case .onlyApps(let ids): mode = .only; checked = Set(ids)
|
||||
case .allExcept(let ids): mode = .except; checked = Set(ids)
|
||||
}
|
||||
self.excludeScreenReader = selection.excludeScreenReader
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func loadView() {
|
||||
view = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 420))
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
buildUI()
|
||||
loadApps()
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private func buildUI() {
|
||||
let titleLabel = NSTextField(labelWithString: "Choose what to share:")
|
||||
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||||
titleLabel.setAccessibilityLabel("Choose what to share")
|
||||
|
||||
let modeControl = NSSegmentedControl(
|
||||
labels: ["Everything", "Only selected", "All except selected"],
|
||||
trackingMode: .selectOne, target: self, action: #selector(modeChanged))
|
||||
modeControl.selectedSegment = mode.rawValue
|
||||
modeControl.segmentDistribution = .fillEqually
|
||||
modeControl.setAccessibilityLabel("Share mode")
|
||||
self.modeControl = modeControl
|
||||
|
||||
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("app"))
|
||||
tableView.addTableColumn(col)
|
||||
tableView.headerView = nil
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.rowHeight = 22
|
||||
tableView.setAccessibilityLabel("Application list")
|
||||
|
||||
let scroll = NSScrollView()
|
||||
scroll.documentView = tableView
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.borderType = .bezelBorder
|
||||
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
scroll.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
|
||||
let statusLabel = NSTextField(labelWithString: "Loading apps…")
|
||||
statusLabel.textColor = .secondaryLabelColor
|
||||
statusLabel.font = .systemFont(ofSize: 11)
|
||||
self.statusLabel = statusLabel
|
||||
|
||||
let screenReaderCheckbox = NSButton(checkboxWithTitle: "Exclude screen reader (VoiceOver) audio",
|
||||
target: self, action: #selector(screenReaderToggled))
|
||||
screenReaderCheckbox.state = excludeScreenReader ? .on : .off
|
||||
screenReaderCheckbox.toolTip = "Keep your VoiceOver speech out of the shared audio."
|
||||
self.screenReaderCheckbox = screenReaderCheckbox
|
||||
|
||||
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked))
|
||||
cancelButton.bezelStyle = .rounded
|
||||
cancelButton.keyEquivalent = "\u{1b}" // Esc
|
||||
|
||||
let shareButton = NSButton(title: "Share", target: self, action: #selector(shareClicked))
|
||||
shareButton.bezelStyle = .rounded
|
||||
shareButton.keyEquivalent = "\r"
|
||||
|
||||
let buttonRow = NSStackView(views: [NSView(), cancelButton, shareButton])
|
||||
buttonRow.orientation = .horizontal
|
||||
buttonRow.spacing = 8
|
||||
|
||||
let stack = NSStackView(views: [titleLabel, modeControl, scroll, statusLabel,
|
||||
screenReaderCheckbox, buttonRow])
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 8
|
||||
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
updateEnabledStates()
|
||||
}
|
||||
|
||||
/// Reflect the current mode: the app table only matters for only/except; the screen-reader
|
||||
/// toggle is moot for `.only` (include-only already excludes the screen reader).
|
||||
private func updateEnabledStates() {
|
||||
let listActive = (mode != .everything)
|
||||
tableView.isEnabled = listActive
|
||||
tableView.alphaValue = listActive ? 1.0 : 0.45
|
||||
screenReaderCheckbox?.isEnabled = (mode != .only)
|
||||
}
|
||||
|
||||
private func loadApps() {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let content = try await SCShareableContent.current
|
||||
var seen = Set<String>()
|
||||
var entries: [AppEntry] = []
|
||||
for app in content.applications {
|
||||
let bid = app.bundleIdentifier
|
||||
guard !bid.isEmpty, !seen.contains(bid) else { continue }
|
||||
// Hide our own app (its audio is already excluded) and the screen reader
|
||||
// (handled by its own toggle).
|
||||
if bid == Bundle.main.bundleIdentifier { continue }
|
||||
if ScreenAudioCapture.screenReaderBundleIDs.contains(bid) { continue }
|
||||
seen.insert(bid)
|
||||
let name = app.applicationName.isEmpty ? bid : app.applicationName
|
||||
let icon = NSRunningApplication
|
||||
.runningApplications(withBundleIdentifier: bid).first?.icon
|
||||
entries.append(AppEntry(name: name, bundleID: bid, icon: icon))
|
||||
}
|
||||
entries.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
self.apps = entries
|
||||
self.statusLabel?.isHidden = true
|
||||
self.tableView.reloadData()
|
||||
} catch {
|
||||
self.statusLabel?.stringValue = "Screen Recording permission needed to list apps — "
|
||||
+ "grant it in System Settings ▸ Privacy & Security, then reopen this."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func modeChanged() {
|
||||
mode = Mode(rawValue: modeControl?.selectedSegment ?? 0) ?? .everything
|
||||
updateEnabledStates()
|
||||
}
|
||||
|
||||
@objc private func screenReaderToggled() {
|
||||
excludeScreenReader = (screenReaderCheckbox?.state == .on)
|
||||
}
|
||||
|
||||
@objc private func appCheckboxToggled(_ sender: NSButton) {
|
||||
let row = sender.tag
|
||||
guard row >= 0, row < apps.count else { return }
|
||||
let bid = apps[row].bundleID
|
||||
if sender.state == .on { checked.insert(bid) } else { checked.remove(bid) }
|
||||
}
|
||||
|
||||
@objc private func shareClicked() {
|
||||
let scope: ScreenAudioScope
|
||||
switch mode {
|
||||
case .everything: scope = .entireDesktop
|
||||
case .only: scope = .onlyApps(Array(checked))
|
||||
case .except: scope = .allExcept(Array(checked))
|
||||
}
|
||||
dismiss(nil)
|
||||
onComplete?(ScreenAudioSelection(scope: scope, excludeScreenReader: excludeScreenReader))
|
||||
}
|
||||
|
||||
@objc private func cancelClicked() {
|
||||
dismiss(nil)
|
||||
onComplete?(nil)
|
||||
}
|
||||
|
||||
// MARK: - NSTableViewDataSource / Delegate
|
||||
|
||||
func numberOfRows(in tableView: NSTableView) -> Int { apps.count }
|
||||
|
||||
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||
let app = apps[row]
|
||||
let cell = AppCheckboxCell()
|
||||
cell.checkbox.title = app.name
|
||||
cell.checkbox.state = checked.contains(app.bundleID) ? .on : .off
|
||||
cell.checkbox.isEnabled = (mode != .everything)
|
||||
cell.checkbox.tag = row
|
||||
cell.checkbox.target = self
|
||||
cell.checkbox.action = #selector(appCheckboxToggled(_:))
|
||||
cell.iconView.image = app.icon
|
||||
return cell
|
||||
}
|
||||
|
||||
// Selecting a row shouldn't visually highlight — interaction is via the checkbox.
|
||||
func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { false }
|
||||
}
|
||||
|
||||
// One row: a leading app icon and a checkbox titled with the app name.
|
||||
private final class AppCheckboxCell: NSTableCellView {
|
||||
let iconView = NSImageView()
|
||||
let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil)
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
iconView.translatesAutoresizingMaskIntoConstraints = false
|
||||
checkbox.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(iconView)
|
||||
addSubview(checkbox)
|
||||
NSLayoutConstraint.activate([
|
||||
iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
|
||||
iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
iconView.widthAnchor.constraint(equalToConstant: 16),
|
||||
iconView.heightAnchor.constraint(equalToConstant: 16),
|
||||
checkbox.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 6),
|
||||
checkbox.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
|
||||
checkbox.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
}
|
||||
@@ -44,7 +44,6 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
|
||||
private func buildUI() {
|
||||
guard let contentView = window?.contentView else { return }
|
||||
|
||||
// Server list
|
||||
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("server"))
|
||||
col.title = "Saved Servers"
|
||||
serverTableView.addTableColumn(col)
|
||||
@@ -61,7 +60,6 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
|
||||
serverScrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(serverScrollView)
|
||||
|
||||
// Buttons row
|
||||
configureButton(addButton, title: "Add…", action: #selector(addClicked))
|
||||
configureButton(editButton, title: "Edit…", action: #selector(editClicked))
|
||||
configureButton(removeButton, title: "Remove", action: #selector(removeClicked))
|
||||
@@ -72,13 +70,11 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
|
||||
buttonStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(buttonStack)
|
||||
|
||||
// Status
|
||||
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
statusLabel.textColor = .secondaryLabelColor
|
||||
statusLabel.setAccessibilityLabel("Connection status")
|
||||
contentView.addSubview(statusLabel)
|
||||
|
||||
// Connect button
|
||||
connectButton.title = "Connect"
|
||||
connectButton.bezelStyle = .rounded
|
||||
connectButton.keyEquivalent = "\r"
|
||||
@@ -216,7 +212,7 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
|
||||
|
||||
switch server.authMode {
|
||||
case .guest:
|
||||
let nick = server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName()
|
||||
let nick = server.nickname?.isEmpty == false ? server.nickname! : NSFullUserName()
|
||||
newClient.authenticateGuest(nick)
|
||||
case .password:
|
||||
let username = server.savedUsername ?? ""
|
||||
@@ -259,7 +255,7 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
|
||||
case .authResult:
|
||||
if event.result == .ok {
|
||||
let nickname = server.authMode == .guest
|
||||
? (server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName())
|
||||
? (server.nickname?.isEmpty == false ? server.nickname! : NSFullUserName())
|
||||
: (server.savedUsername ?? "")
|
||||
authSucceeded(client: client!, selfUserId: event.userId, nickname: nickname)
|
||||
} else {
|
||||
@@ -376,7 +372,3 @@ extension ConnectWindowController: NSTableViewDataSource, NSTableViewDelegate {
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper
|
||||
|
||||
|
||||
|
||||
@@ -31,8 +31,15 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
internal var micStreamId: UInt32 = 0
|
||||
private var screenStreamId: UInt32 = 0
|
||||
private var screenCapture: ScreenAudioCapture?
|
||||
internal var pttKeyCode: UInt16 = 0x60 // F8
|
||||
private var auxStreamId: UInt32 = 0 // 0 = aux (second input device) stream not active
|
||||
private var auxCapture: InputDeviceCapture? // client-side capture feeding the aux stream
|
||||
// Last app/exclusion choice from the share picker; reused as the default next time.
|
||||
private var screenAudioSelection: ScreenAudioSelection = .default
|
||||
internal var pttKeyCode: UInt16 = 0x60 { // F8
|
||||
didSet { UserDefaults.standard.set(Int(pttKeyCode), forKey: AudioDefaults.pttKeyCode) }
|
||||
}
|
||||
private var pttMonitor: Any?
|
||||
private var pttEngaged = false // guards the PTT cue against key-repeat
|
||||
private var serverMuted = false
|
||||
private var serverDeafened = false
|
||||
private var channelTree: [ChannelNode] = []
|
||||
@@ -46,11 +53,56 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
private var settingsWindowController: SettingsWindowController?
|
||||
|
||||
// MARK: - Audio settings state (source of truth — read/written by SettingsWindowController)
|
||||
// The input mode / VAD threshold / mic gain / PTT key persist via UserDefaults (didSet below)
|
||||
// so they survive relaunch; loadPersistedAudioSettings() restores them at startup and they are
|
||||
// pushed into the core when the mic stream starts (micToggleClicked).
|
||||
|
||||
internal var selectedInputMode: VoiceCatInputMode = .voiceActivation
|
||||
internal var vadThresholdValue: Float = 0.05
|
||||
enum AudioDefaults {
|
||||
static let inputMode = "voice.inputMode"
|
||||
static let vadThreshold = "voice.vadThreshold"
|
||||
static let inputGain = "voice.inputGain"
|
||||
static let inputNoiseReduction = "voice.inputNoiseReduction"
|
||||
static let stereoMic = "voice.stereoMic"
|
||||
static let pttKeyCode = "voice.pttKeyCode"
|
||||
static let auxEnabled = "voice.auxEnabled"
|
||||
static let auxDeviceUID = "voice.auxDeviceUID"
|
||||
static let auxGain = "voice.auxGain"
|
||||
}
|
||||
|
||||
internal var selectedInputMode: VoiceCatInputMode = .voiceActivation {
|
||||
didSet { UserDefaults.standard.set(Int(selectedInputMode.rawValue), forKey: AudioDefaults.inputMode) }
|
||||
}
|
||||
internal var vadThresholdValue: Float = 0.05 {
|
||||
didSet { UserDefaults.standard.set(vadThresholdValue, forKey: AudioDefaults.vadThreshold) }
|
||||
}
|
||||
internal var inputGain: Float = 1.0 {
|
||||
didSet { UserDefaults.standard.set(inputGain, forKey: AudioDefaults.inputGain) }
|
||||
}
|
||||
internal var inputNoiseReduction: Bool = false {
|
||||
didSet { UserDefaults.standard.set(inputNoiseReduction, forKey: AudioDefaults.inputNoiseReduction) }
|
||||
}
|
||||
// Capture the mic in stereo (interleaved L/R) instead of mono. Real stereo only reaches the
|
||||
// wire on a stereo channel; the core folds a stereo mic to mono on a mono channel. Applied to
|
||||
// the core when the mic stream starts (micToggleClicked) and live via SettingsWindowController.
|
||||
internal var stereoMic: Bool = false {
|
||||
didSet { UserDefaults.standard.set(stereoMic, forKey: AudioDefaults.stereoMic) }
|
||||
}
|
||||
internal var selectedInputDeviceId: String?
|
||||
|
||||
// Aux outgoing stream: a second hardware input device the client captures itself and feeds to
|
||||
// the core (kind = AUX_DEVICE, external_feed). Device + volume only — aux is always-on (the
|
||||
// core never gates AUX_DEVICE on VAD/PTT). auxDeviceUID is a Core Audio device UID (stable),
|
||||
// NOT a core/miniaudio id. auxGain is read live by the capture feed, so the slider is instant.
|
||||
internal var auxEnabled: Bool = false {
|
||||
didSet { UserDefaults.standard.set(auxEnabled, forKey: AudioDefaults.auxEnabled) }
|
||||
}
|
||||
internal var auxDeviceUID: String? {
|
||||
didSet { UserDefaults.standard.set(auxDeviceUID, forKey: AudioDefaults.auxDeviceUID) }
|
||||
}
|
||||
internal var auxGain: Float = 1.0 {
|
||||
didSet { UserDefaults.standard.set(auxGain, forKey: AudioDefaults.auxGain) }
|
||||
}
|
||||
|
||||
// MARK: - UI components
|
||||
private let channelOutlineView = NSOutlineView()
|
||||
private let userTableView = NSTableView()
|
||||
@@ -94,17 +146,46 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
super.init(window: window)
|
||||
window.delegate = self
|
||||
|
||||
loadPersistedAudioSettings()
|
||||
buildUI()
|
||||
buildToolbar()
|
||||
wireEvents()
|
||||
bootstrap()
|
||||
}
|
||||
|
||||
/// Restore the saved input mode / VAD threshold / mic gain / PTT key from UserDefaults so a
|
||||
/// relaunch keeps the user's transmission settings instead of resetting to VAD defaults.
|
||||
private func loadPersistedAudioSettings() {
|
||||
let d = UserDefaults.standard
|
||||
if d.object(forKey: AudioDefaults.inputMode) != nil {
|
||||
let raw = UInt32(d.integer(forKey: AudioDefaults.inputMode))
|
||||
selectedInputMode = VoiceCatInputMode(rawValue: raw) ?? .voiceActivation
|
||||
}
|
||||
if d.object(forKey: AudioDefaults.vadThreshold) != nil {
|
||||
vadThresholdValue = d.float(forKey: AudioDefaults.vadThreshold)
|
||||
}
|
||||
if d.object(forKey: AudioDefaults.inputGain) != nil {
|
||||
inputGain = d.float(forKey: AudioDefaults.inputGain)
|
||||
}
|
||||
if d.object(forKey: AudioDefaults.inputNoiseReduction) != nil {
|
||||
inputNoiseReduction = d.bool(forKey: AudioDefaults.inputNoiseReduction)
|
||||
}
|
||||
stereoMic = d.bool(forKey: AudioDefaults.stereoMic)
|
||||
if d.object(forKey: AudioDefaults.pttKeyCode) != nil {
|
||||
pttKeyCode = UInt16(d.integer(forKey: AudioDefaults.pttKeyCode))
|
||||
}
|
||||
auxEnabled = d.bool(forKey: AudioDefaults.auxEnabled)
|
||||
if d.object(forKey: AudioDefaults.auxDeviceUID) != nil {
|
||||
auxDeviceUID = d.string(forKey: AudioDefaults.auxDeviceUID)
|
||||
}
|
||||
if d.object(forKey: AudioDefaults.auxGain) != nil {
|
||||
auxGain = d.float(forKey: AudioDefaults.auxGain)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
deinit {
|
||||
NSLog("[VoiceCatMac] MainWindowController deinit — client and event handlers are gone")
|
||||
}
|
||||
deinit {}
|
||||
|
||||
// MARK: - UI construction
|
||||
|
||||
@@ -341,7 +422,11 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
pttMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in
|
||||
guard let self, self.selectedInputMode == .pushToTalk,
|
||||
self.micStreamId != 0, event.keyCode == self.pttKeyCode else { return event }
|
||||
self.client.setPushToTalk(event.type == .keyDown)
|
||||
let down = event.type == .keyDown
|
||||
self.client.setPushToTalk(down)
|
||||
// Cue only on the press transition — key-down auto-repeats while held.
|
||||
if down && !self.pttEngaged { EventFeedback.shared.play(.ptt) }
|
||||
self.pttEngaged = down
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -368,10 +453,6 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
}
|
||||
ownPermissions = client.getPermissions()
|
||||
|
||||
NSLog("[VoiceCatMac] bootstrap: channels=%d users=%d perms{admin=%d kick=%d} currentChannelId=%u",
|
||||
channels.count, allUsers.count,
|
||||
ownPermissions.isAdmin, ownPermissions.canKick, currentChannelId)
|
||||
|
||||
// Apply initial output volume (default 80% — matches Windows client)
|
||||
client.setOutputVolume(0.8)
|
||||
|
||||
@@ -383,14 +464,13 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
buildMessagesMenu()
|
||||
buildAdminMenu()
|
||||
addActivity("Connected to server as \(nickname)")
|
||||
EventFeedback.shared.play(.login)
|
||||
EventFeedback.shared.speak("Connected")
|
||||
}
|
||||
|
||||
// MARK: - Event handling
|
||||
|
||||
private func handleEvent(_ event: VoiceCatEvent) {
|
||||
NSLog("[VoiceCatMac] event type=%d result=%d userId=%u channelId=%u streamId=%u text=%@",
|
||||
event.type.rawValue, event.result.rawValue, event.userId, event.channelId,
|
||||
event.streamId, event.text ?? "(nil)")
|
||||
switch event.type {
|
||||
case .channelList:
|
||||
channels = client.listChannels()
|
||||
@@ -408,11 +488,14 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
isGuest: true,
|
||||
channelId: event.channelId,
|
||||
selfMicMuted: false, selfDeafened: false,
|
||||
serverMuted: false, serverDeafened: false)
|
||||
serverMuted: false, serverDeafened: false,
|
||||
voiceSubscribed: false)
|
||||
users[event.userId] = u
|
||||
refreshChannelTree(); refreshUserList()
|
||||
if event.channelId == currentChannelId && event.userId != selfUserId {
|
||||
addActivity("\(u.nickname) joined the channel")
|
||||
EventFeedback.shared.play(.channelJoin)
|
||||
EventFeedback.shared.speak("\(u.nickname) joined")
|
||||
}
|
||||
|
||||
case .userLeft:
|
||||
@@ -421,7 +504,11 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
users.removeValue(forKey: event.userId)
|
||||
talkingUsers.remove(event.userId)
|
||||
refreshChannelTree(); refreshUserList()
|
||||
if wasHere { addActivity("\(nick) left the channel") }
|
||||
if wasHere {
|
||||
addActivity("\(nick) left the channel")
|
||||
EventFeedback.shared.play(.channelLeave)
|
||||
EventFeedback.shared.speak("\(nick) left")
|
||||
}
|
||||
if let pmWin = pmWindows[event.userId] {
|
||||
pmWin.appendActivity("\(nick) disconnected from server")
|
||||
}
|
||||
@@ -445,7 +532,8 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
users[selfUserId] = User(id: self_.id, nickname: self_.nickname, isGuest: self_.isGuest,
|
||||
channelId: event.channelId,
|
||||
selfMicMuted: self_.selfMicMuted, selfDeafened: self_.selfDeafened,
|
||||
serverMuted: self_.serverMuted, serverDeafened: self_.serverDeafened)
|
||||
serverMuted: self_.serverMuted, serverDeafened: self_.serverDeafened,
|
||||
voiceSubscribed: self_.voiceSubscribed)
|
||||
}
|
||||
refreshChannelTree(); refreshUserList(); updateStatusLabel()
|
||||
let name = channels.first(where: { $0.id == event.channelId })?.name ?? "Channel #\(event.channelId)"
|
||||
@@ -471,6 +559,9 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
let talking = event.u32a == 1
|
||||
if talking { talkingUsers.insert(event.userId) } else { talkingUsers.remove(event.userId) }
|
||||
refreshUserList()
|
||||
if event.userId == selfUserId {
|
||||
EventFeedback.shared.play(talking ? .vaStart : .vaStop)
|
||||
}
|
||||
if talking && event.userId != selfUserId,
|
||||
let u = users[event.userId], u.channelId == currentChannelId {
|
||||
addActivity("\(u.nickname) started talking")
|
||||
@@ -491,10 +582,18 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
addActivity("\(u.nickname) started \(kindStr) stream")
|
||||
|
||||
case .streamStopped:
|
||||
if let u = users[event.userId], u.channelId == currentChannelId {
|
||||
if event.userId == selfUserId {
|
||||
if event.streamId == micStreamId {
|
||||
micStreamId = 0
|
||||
settingsWindowController?.resetLevel()
|
||||
}
|
||||
} else if let u = users[event.userId], u.channelId == currentChannelId {
|
||||
addActivity("\(u.nickname) stopped a stream")
|
||||
}
|
||||
|
||||
case .voiceState:
|
||||
handleVoiceState(event)
|
||||
|
||||
case .disconnected:
|
||||
handleDisconnected(event)
|
||||
|
||||
@@ -519,21 +618,28 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short)
|
||||
}
|
||||
let sender = nickname(for: event.userId)
|
||||
let isSelf = event.userId == selfUserId
|
||||
let body = event.text ?? ""
|
||||
|
||||
if event.textScope == .private {
|
||||
// Route PMs to per-conversation windows. For our own outgoing PM, ev.channelId
|
||||
// carries the recipient user ID; for incoming, ev.userId is the sender.
|
||||
let otherId = event.userId == selfUserId ? event.channelId : event.userId
|
||||
let otherId = isSelf ? event.channelId : event.userId
|
||||
let win = getOrOpenPmWindow(otherId)
|
||||
win.appendMessage(time: time, isSelf: event.userId == selfUserId, sender: sender,
|
||||
text: event.text ?? "")
|
||||
if event.userId != selfUserId {
|
||||
win.appendMessage(time: time, isSelf: isSelf, sender: sender, text: body)
|
||||
EventFeedback.shared.play(isSelf ? .pmSent : .pmRecv)
|
||||
if !isSelf {
|
||||
addActivity("Private message from \(sender)")
|
||||
EventFeedback.shared.speak("Private message from \(sender): \(body)")
|
||||
}
|
||||
} else {
|
||||
let line = "[\(time)] \(sender): \(event.text ?? "")\n"
|
||||
let line = "[\(time)] \(sender): \(body)\n"
|
||||
logTextView.textStorage?.append(NSAttributedString(string: line))
|
||||
logTextView.scrollToEndOfDocument(nil)
|
||||
EventFeedback.shared.play(isSelf ? .channelSent : .channelRecv)
|
||||
if !isSelf {
|
||||
EventFeedback.shared.speak("\(sender): \(body)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,11 +711,14 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
let msg = event.text.map { "Disconnected: \($0)" } ?? "Disconnected from server."
|
||||
statusLabel.stringValue = msg
|
||||
addActivity(msg)
|
||||
EventFeedback.shared.play(event.result == .ok ? .logout : .connectionLost)
|
||||
EventFeedback.shared.speak(event.result == .ok ? "Disconnected" : "Connection lost")
|
||||
channelTree = []; channelOutlineView.reloadData()
|
||||
displayedUsers = []; userTableView.reloadData()
|
||||
users.removeAll(); talkingUsers.removeAll()
|
||||
stopScreenCapture()
|
||||
currentChannelId = 0; micStreamId = 0; screenStreamId = 0
|
||||
auxCapture?.stop(); auxCapture = nil // connection gone — drop capture, no stopStream
|
||||
currentChannelId = 0; micStreamId = 0; screenStreamId = 0; auxStreamId = 0
|
||||
composeField.isEnabled = false; sendButton.isEnabled = false
|
||||
joinVoiceButton?.isEnabled = false
|
||||
shareScreenButton?.isEnabled = false
|
||||
@@ -675,30 +784,127 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
|
||||
@objc private func micToggleClicked() {
|
||||
if micStreamId == 0 {
|
||||
let result = client.joinVoice()
|
||||
if result != .ok {
|
||||
addActivity("Failed to join voice: \(result)")
|
||||
}
|
||||
} else {
|
||||
stopAuxStream()
|
||||
if screenStreamId != 0 { stopScreenAudio() }
|
||||
client.setPushToTalk(false)
|
||||
client.leaveVoice()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleVoiceState(_ event: VoiceCatEvent) {
|
||||
let subscribed = event.u32a != 0
|
||||
if subscribed {
|
||||
let (result, streamId) = client.startStream(StreamDescriptor(kind: .mic, deviceId: nil, label: "Microphone"))
|
||||
if result == .ok {
|
||||
micStreamId = streamId
|
||||
if let devId = selectedInputDeviceId {
|
||||
client.setInputDevice(streamId: streamId, deviceId: devId)
|
||||
}
|
||||
client.setCaptureChannels(streamId: streamId, channels: stereoMic ? 2 : 1)
|
||||
client.setInputMode(selectedInputMode)
|
||||
if selectedInputMode == .voiceActivation {
|
||||
client.setVadThreshold(vadThresholdValue)
|
||||
}
|
||||
client.setInputGain(inputGain)
|
||||
client.setInputNoiseReduction(inputNoiseReduction)
|
||||
setVoiceJoinedState(true)
|
||||
addActivity("Joined voice — microphone active")
|
||||
EventFeedback.shared.play(.voiceOn)
|
||||
NSAccessibility.post(element: logTextView, notification: .announcementRequested,
|
||||
userInfo: [.announcement: "Joined voice", .priority: NSAccessibilityPriorityLevel.medium])
|
||||
startAuxStream()
|
||||
} else {
|
||||
addActivity("Failed to start microphone: \(result)")
|
||||
}
|
||||
} else {
|
||||
client.setPushToTalk(false)
|
||||
client.stopStream(micStreamId)
|
||||
micStreamId = 0
|
||||
settingsWindowController?.resetLevel()
|
||||
setVoiceJoinedState(false)
|
||||
addActivity("Left voice")
|
||||
EventFeedback.shared.play(.voiceOff)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Aux input stream (second hardware input device)
|
||||
|
||||
/// Called by SettingsWindowController when the user toggles the aux checkbox.
|
||||
func applyAuxEnabled(_ on: Bool) {
|
||||
auxEnabled = on
|
||||
guard micStreamId != 0 else { return } // not in voice — applied on next Join Voice
|
||||
if on { startAuxStream() } else { stopAuxStream() }
|
||||
}
|
||||
|
||||
/// Called by SettingsWindowController when the user picks a different aux device.
|
||||
func applyAuxDevice(_ uid: String?) {
|
||||
auxDeviceUID = uid
|
||||
if auxStreamId != 0 { restartAuxCapture() }
|
||||
}
|
||||
|
||||
private func startAuxStream() {
|
||||
guard auxStreamId == 0, auxEnabled else { return }
|
||||
let (result, streamId) = client.startStream(
|
||||
StreamDescriptor(kind: .auxDevice, deviceId: nil, label: "Aux device", externalFeed: true))
|
||||
guard result == .ok else {
|
||||
addActivity("Failed to start aux stream: \(result)")
|
||||
return
|
||||
}
|
||||
auxStreamId = streamId
|
||||
startAuxCapture()
|
||||
addActivity("Aux input stream active")
|
||||
}
|
||||
|
||||
private func startAuxCapture() {
|
||||
let capture = InputDeviceCapture(deviceUID: auxDeviceUID) { [weak self] ptr, spc, ch in
|
||||
self?.feedAux(ptr, samplesPerChannel: spc, channels: ch)
|
||||
}
|
||||
auxCapture = capture
|
||||
do { try capture.start() }
|
||||
catch {
|
||||
addActivity("Failed to open aux input device")
|
||||
stopAuxStream()
|
||||
}
|
||||
}
|
||||
|
||||
// Re-open the capture on a different device while the aux stream stays up (the core stream id
|
||||
// is unchanged — only the client-side capture source changes).
|
||||
private func restartAuxCapture() {
|
||||
guard auxStreamId != 0 else { return }
|
||||
auxCapture?.stop()
|
||||
auxCapture = nil
|
||||
startAuxCapture()
|
||||
}
|
||||
|
||||
private func stopAuxStream() {
|
||||
auxCapture?.stop()
|
||||
auxCapture = nil
|
||||
if auxStreamId != 0 {
|
||||
client.stopStream(auxStreamId)
|
||||
auxStreamId = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Fired on the capture's realtime thread. feedPcm is thread-safe. Gain is read live from
|
||||
// auxGain each frame so the volume slider takes effect immediately.
|
||||
private func feedAux(_ pcm: UnsafePointer<Int16>, samplesPerChannel: Int, channels: UInt32) {
|
||||
guard auxStreamId != 0 else { return }
|
||||
let gain = auxGain
|
||||
if gain != 1.0 {
|
||||
let n = samplesPerChannel * Int(channels)
|
||||
var scaled = [Int16](repeating: 0, count: n)
|
||||
for i in 0..<n {
|
||||
let v = (Float(pcm[i]) * gain).rounded()
|
||||
scaled[i] = Int16(max(-32768, min(32767, v)))
|
||||
}
|
||||
client.feedPcm(streamId: auxStreamId, pcm: scaled,
|
||||
samplesPerChannel: samplesPerChannel, channels: channels)
|
||||
} else {
|
||||
client.feedPcm(streamId: auxStreamId, pcm: pcm,
|
||||
samplesPerChannel: samplesPerChannel, channels: channels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,9 +926,38 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
|
||||
@objc private func screenAudioClicked() {
|
||||
if screenStreamId == 0 {
|
||||
// Announce the stream now; ScreenCaptureKit capture starts once the server's
|
||||
// StreamAnnounceResult lands (the .streamStarted event), when the effective audio
|
||||
// config — and thus the channel count to capture — is known. See startScreenCapture.
|
||||
// Let the user pick what to share (apps to include/exclude, screen-reader audio)
|
||||
// before we announce anything. The picker remembers the previous choice.
|
||||
let sheet = ScreenSharePickerSheet(selection: screenAudioSelection)
|
||||
sheet.onComplete = { [weak self] selection in
|
||||
guard let self, let selection else { return } // nil = cancelled
|
||||
self.screenAudioSelection = selection
|
||||
self.beginScreenAudioShare()
|
||||
}
|
||||
presentSheet(sheet)
|
||||
} else {
|
||||
stopScreenCapture()
|
||||
client.stopStream(screenStreamId)
|
||||
screenStreamId = 0
|
||||
setShareScreenButton(active: false)
|
||||
addActivity("Stopped sharing screen audio")
|
||||
}
|
||||
}
|
||||
|
||||
private func stopScreenAudio() {
|
||||
guard screenStreamId != 0 else { return }
|
||||
stopScreenCapture()
|
||||
client.stopStream(screenStreamId)
|
||||
screenStreamId = 0
|
||||
setShareScreenButton(active: false)
|
||||
addActivity("Stopped sharing screen audio")
|
||||
}
|
||||
|
||||
/// Announce the SCREEN_AUDIO stream with the chosen selection in hand. ScreenCaptureKit
|
||||
/// capture starts once the server's StreamAnnounceResult lands (the .streamStarted event),
|
||||
/// when the effective audio config — and thus the channel count — is known. See
|
||||
/// startScreenCapture.
|
||||
private func beginScreenAudioShare() {
|
||||
let (result, streamId) = client.startStream(StreamDescriptor(kind: .screenAudio, deviceId: nil, label: "Desktop audio"))
|
||||
if result == .ok {
|
||||
screenStreamId = streamId
|
||||
@@ -731,13 +966,6 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
} else {
|
||||
addActivity("Failed to start screen audio: \(result)")
|
||||
}
|
||||
} else {
|
||||
stopScreenCapture()
|
||||
client.stopStream(screenStreamId)
|
||||
screenStreamId = 0
|
||||
setShareScreenButton(active: false)
|
||||
addActivity("Stopped sharing screen audio")
|
||||
}
|
||||
}
|
||||
|
||||
private func setShareScreenButton(active: Bool) {
|
||||
@@ -760,7 +988,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
let channels: UInt32 = (cfgResult == .ok && cfg?.stereo == true) ? 2 : 1
|
||||
|
||||
let streamId = screenStreamId
|
||||
let capture = ScreenAudioCapture(channels: channels) { [weak self] pcm, samples, ch in
|
||||
let capture = ScreenAudioCapture(channels: channels, selection: screenAudioSelection) { [weak self] pcm, samples, ch in
|
||||
self?.client.feedPcm(streamId: streamId, pcm: pcm, samplesPerChannel: samples, channels: ch)
|
||||
}
|
||||
screenCapture = capture
|
||||
@@ -768,7 +996,8 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await capture.start()
|
||||
addActivity("Started sharing screen audio (\(channels == 2 ? "stereo" : "mono"))")
|
||||
addActivity("Started sharing screen audio (\(channels == 2 ? "stereo" : "mono"))"
|
||||
+ "\(Self.scopeSuffix(for: screenAudioSelection))")
|
||||
} catch {
|
||||
// Most commonly: Screen Recording permission denied. Roll back the stream.
|
||||
screenCapture = nil
|
||||
@@ -788,6 +1017,21 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
screenCapture = nil
|
||||
}
|
||||
|
||||
/// A short human-readable description of the share scope for the activity log.
|
||||
private static func scopeSuffix(for selection: ScreenAudioSelection) -> String {
|
||||
var parts: [String] = []
|
||||
switch selection.scope {
|
||||
case .entireDesktop: break
|
||||
case .onlyApps(let ids): if !ids.isEmpty { parts.append("only \(ids.count) app(s)") }
|
||||
case .allExcept(let ids): if !ids.isEmpty { parts.append("excluding \(ids.count) app(s)") }
|
||||
}
|
||||
// For .onlyApps the screen reader is already excluded, so don't claim it twice.
|
||||
if selection.excludeScreenReader {
|
||||
if case .onlyApps = selection.scope {} else { parts.append("no screen reader") }
|
||||
}
|
||||
return parts.isEmpty ? "" : " — " + parts.joined(separator: ", ")
|
||||
}
|
||||
|
||||
@objc private func muteChanged() {
|
||||
let muted = muteButton?.state == .on
|
||||
let deafened = deafenButton?.state == .on
|
||||
@@ -814,7 +1058,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
composeField.stringValue = ""
|
||||
}
|
||||
|
||||
// MARK: - M5: Moderation helpers
|
||||
// MARK: - Moderation helpers
|
||||
|
||||
private func moveUser(_ user: User) {
|
||||
let sheet = MoveUserSheet(channels: channels, currentChannelId: user.channelId)
|
||||
@@ -988,7 +1232,6 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
presentSheet(sheet)
|
||||
}
|
||||
|
||||
/// Open a PM window from the user context menu.
|
||||
private func openPmWindow(_ user: User) {
|
||||
getOrOpenPmWindow(user.id)
|
||||
}
|
||||
@@ -1035,17 +1278,13 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
if let mon = pttMonitor { NSEvent.removeMonitor(mon) }
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
// Close all PM windows
|
||||
for (_, pmWin) in pmWindows { pmWin.close() }
|
||||
pmWindows.removeAll()
|
||||
// Close settings window
|
||||
settingsWindowController?.close()
|
||||
settingsWindowController = nil
|
||||
// Remove app menus we added
|
||||
if let item = voiceMenuItem { NSApp.mainMenu?.removeItem(item) }
|
||||
if let item = messagesMenuItem { NSApp.mainMenu?.removeItem(item) }
|
||||
if let item = adminMenuItem { NSApp.mainMenu?.removeItem(item) }
|
||||
// Remove Settings menu item + separator from app menu
|
||||
if let appMenu = NSApp.mainMenu?.item(at: 0)?.submenu {
|
||||
if let item = settingsMenuItem { appMenu.removeItem(item) }
|
||||
// Remove the separator we inserted before Quit
|
||||
@@ -1060,6 +1299,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
}
|
||||
client.setPushToTalk(false)
|
||||
stopScreenCapture()
|
||||
if auxStreamId != 0 { stopAuxStream() }
|
||||
if screenStreamId != 0 { client.stopStream(screenStreamId) }
|
||||
if micStreamId != 0 { client.stopStream(micStreamId) }
|
||||
client.onLevel = nil
|
||||
@@ -1224,7 +1464,7 @@ extension MainWindowController: NSMenuDelegate {
|
||||
let info = ChannelEdit(id: ch.id, parentId: ch.parentId, name: ch.name,
|
||||
topic: ch.topic, passwordProtected: ch.passwordProtected,
|
||||
password: nil, maxUsers: ch.maxUsers,
|
||||
sortOrder: 0, audio: AudioConfig())
|
||||
sortOrder: ch.sortOrder, audio: ch.audio)
|
||||
let sheet = ChannelEditSheet(channels: channels, editing: info)
|
||||
sheet.onComplete = { [weak self] edited in
|
||||
guard let edited else { return }
|
||||
|
||||
@@ -50,6 +50,56 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
// Cached VAD slider position so we can restore it when the window reopens.
|
||||
private var vadSliderValue: Double = 50
|
||||
|
||||
// Mic input gain: 0–400 % (100 = unity). Persisted via MainWindowController.inputGain.
|
||||
private let inputGainSlider: NSSlider = {
|
||||
let s = NSSlider(value: 100, minValue: 0, maxValue: 400, target: nil, action: nil)
|
||||
s.numberOfTickMarks = 0
|
||||
return s
|
||||
}()
|
||||
private let inputGainValueLabel = NSTextField(labelWithString: "100%")
|
||||
|
||||
// Send-side mic noise reduction (RNNoise). MIC-only. Persisted via
|
||||
// MainWindowController.inputNoiseReduction.
|
||||
private let nrCheckbox = NSButton(checkboxWithTitle: "Noise reduction (RNNoise)",
|
||||
target: nil, action: nil)
|
||||
|
||||
// Capture the mic in stereo (interleaved L/R) instead of mono. Real stereo only reaches the
|
||||
// wire on a stereo channel; the core folds a stereo mic to mono on a mono channel. Persisted
|
||||
// via MainWindowController.stereoMic.
|
||||
private let stereoMicCheckbox = NSButton(checkboxWithTitle: "Stereo microphone",
|
||||
target: nil, action: nil)
|
||||
|
||||
// Aux input stream: a second outgoing stream from another hardware input device (e.g. line-in
|
||||
// / aux), captured client-side. Device + volume only — aux is always-on. Persisted via
|
||||
// MainWindowController.auxEnabled / auxDeviceUID / auxGain.
|
||||
private let auxCheckbox = NSButton(checkboxWithTitle: "Aux input stream (second device)",
|
||||
target: nil, action: nil)
|
||||
private let auxDevicePicker = NSPopUpButton()
|
||||
private let auxRefreshButton = NSButton()
|
||||
private let auxGainSlider: NSSlider = {
|
||||
let s = NSSlider(value: 100, minValue: 0, maxValue: 400, target: nil, action: nil)
|
||||
s.numberOfTickMarks = 0
|
||||
return s
|
||||
}()
|
||||
private let auxGainValueLabel = NSTextField(labelWithString: "100%")
|
||||
private let auxDeviceLabel = NSTextField(labelWithString: "Aux device:")
|
||||
private let auxGainLabel = NSTextField(labelWithString: "Aux volume:")
|
||||
|
||||
// Notification feedback controls. Read/write UserDefaults with the same keys VoiceCatCore's
|
||||
// FeedbackSettings reads, so EventFeedback honours these immediately.
|
||||
private let soundsCheckbox = NSButton(checkboxWithTitle: "Event sounds", target: nil, action: nil)
|
||||
private let soundsVolumeSlider: NSSlider = {
|
||||
let s = NSSlider(value: 1, minValue: 0, maxValue: 1, target: nil, action: nil)
|
||||
s.numberOfTickMarks = 0
|
||||
return s
|
||||
}()
|
||||
private let speechCheckbox = NSButton(checkboxWithTitle: "Speak events (text-to-speech)",
|
||||
target: nil, action: nil)
|
||||
private let selfTalkCheckbox = NSButton(checkboxWithTitle: "Your own voice-activity sounds",
|
||||
target: nil, action: nil)
|
||||
private let pttSoundCheckbox = NSButton(checkboxWithTitle: "Push-to-talk cue",
|
||||
target: nil, action: nil)
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(client: VoiceCatClient, mainController: MainWindowController) {
|
||||
@@ -57,13 +107,13 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
self.mainController = mainController
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 380, height: 260),
|
||||
contentRect: NSRect(x: 0, y: 0, width: 380, height: 420),
|
||||
styleMask: [.titled, .closable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Audio Settings"
|
||||
window.minSize = NSSize(width: 340, height: 220)
|
||||
window.title = "Settings"
|
||||
window.minSize = NSSize(width: 340, height: 380)
|
||||
window.center()
|
||||
super.init(window: window)
|
||||
window.delegate = self
|
||||
@@ -71,6 +121,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
buildUI()
|
||||
syncFromMainController()
|
||||
loadInputDevices()
|
||||
loadAuxDevices()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
@@ -120,6 +171,24 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
levelMeter.setAccessibilityLabel("Microphone input level")
|
||||
levelMeter.setAccessibilityHelp("Shows current microphone volume level")
|
||||
|
||||
let inputGainLabel = NSTextField(labelWithString: "Mic volume:")
|
||||
inputGainLabel.setAccessibilityLabel("Microphone volume")
|
||||
inputGainSlider.target = self
|
||||
inputGainSlider.action = #selector(inputGainChanged)
|
||||
inputGainSlider.setAccessibilityLabel("Microphone volume")
|
||||
inputGainSlider.setAccessibilityHelp("Boost a quiet microphone. 100% is unity.")
|
||||
inputGainValueLabel.setAccessibilityLabel("Microphone volume value")
|
||||
|
||||
nrCheckbox.target = self
|
||||
nrCheckbox.action = #selector(nrChanged)
|
||||
nrCheckbox.setAccessibilityLabel("Microphone noise reduction")
|
||||
nrCheckbox.setAccessibilityHelp("RNNoise denoising of your microphone. Cleans your signal for everyone.")
|
||||
|
||||
stereoMicCheckbox.target = self
|
||||
stereoMicCheckbox.action = #selector(stereoMicChanged)
|
||||
stereoMicCheckbox.setAccessibilityLabel("Stereo microphone")
|
||||
stereoMicCheckbox.setAccessibilityHelp("Capture your microphone in stereo. Only transmitted in stereo on a stereo channel.")
|
||||
|
||||
let inputModeRow = NSStackView(views: [inputModeLabel, inputModeControl])
|
||||
inputModeRow.orientation = .horizontal
|
||||
inputModeRow.spacing = 8
|
||||
@@ -140,7 +209,71 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
levelRow.orientation = .horizontal
|
||||
levelRow.spacing = 8
|
||||
|
||||
let stack = NSStackView(views: [inputModeRow, vadRow, pttRow, deviceRow, levelRow])
|
||||
let inputGainRow = NSStackView(views: [inputGainLabel, inputGainSlider, inputGainValueLabel])
|
||||
inputGainRow.orientation = .horizontal
|
||||
inputGainRow.spacing = 8
|
||||
|
||||
// ── Aux input stream ──────────────────────────────────────────────────
|
||||
let auxHeader = NSTextField(labelWithString: "Aux input stream")
|
||||
auxHeader.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||
|
||||
auxCheckbox.target = self
|
||||
auxCheckbox.action = #selector(auxEnabledChanged)
|
||||
auxCheckbox.setAccessibilityLabel("Enable aux input stream")
|
||||
auxCheckbox.setAccessibilityHelp("Transmit a second hardware input device alongside your microphone.")
|
||||
|
||||
auxDeviceLabel.setAccessibilityLabel("Aux input device")
|
||||
auxDevicePicker.setAccessibilityLabel("Aux input device")
|
||||
auxDevicePicker.target = self
|
||||
auxDevicePicker.action = #selector(auxDeviceChanged)
|
||||
auxRefreshButton.title = "↺"
|
||||
auxRefreshButton.bezelStyle = .rounded
|
||||
auxRefreshButton.target = self
|
||||
auxRefreshButton.action = #selector(refreshAuxDevicesClicked)
|
||||
auxRefreshButton.setAccessibilityLabel("Refresh aux device list")
|
||||
auxRefreshButton.toolTip = "Refresh"
|
||||
|
||||
auxGainLabel.setAccessibilityLabel("Aux volume")
|
||||
auxGainSlider.target = self
|
||||
auxGainSlider.action = #selector(auxGainChanged)
|
||||
auxGainSlider.setAccessibilityLabel("Aux volume")
|
||||
auxGainSlider.setAccessibilityHelp("Volume of the aux input stream. 100% is unity.")
|
||||
auxGainValueLabel.setAccessibilityLabel("Aux volume value")
|
||||
|
||||
let auxDeviceRow = NSStackView(views: [auxDeviceLabel, auxDevicePicker, auxRefreshButton])
|
||||
auxDeviceRow.orientation = .horizontal
|
||||
auxDeviceRow.spacing = 8
|
||||
|
||||
let auxGainRow = NSStackView(views: [auxGainLabel, auxGainSlider, auxGainValueLabel])
|
||||
auxGainRow.orientation = .horizontal
|
||||
auxGainRow.spacing = 8
|
||||
|
||||
// Notifications
|
||||
let notificationsHeader = NSTextField(labelWithString: "Notifications")
|
||||
notificationsHeader.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||
|
||||
for box in [soundsCheckbox, speechCheckbox, selfTalkCheckbox, pttSoundCheckbox] {
|
||||
box.target = self
|
||||
box.action = #selector(notificationSettingChanged)
|
||||
}
|
||||
soundsCheckbox.setAccessibilityLabel("Play event sounds")
|
||||
speechCheckbox.setAccessibilityLabel("Speak events")
|
||||
selfTalkCheckbox.setAccessibilityLabel("Your own voice-activity sounds")
|
||||
pttSoundCheckbox.setAccessibilityLabel("Push-to-talk cue")
|
||||
|
||||
let volumeLabel = NSTextField(labelWithString: "Sound volume:")
|
||||
volumeLabel.setAccessibilityLabel("Sound volume")
|
||||
soundsVolumeSlider.target = self
|
||||
soundsVolumeSlider.action = #selector(notificationSettingChanged)
|
||||
soundsVolumeSlider.setAccessibilityLabel("Sound volume")
|
||||
let volumeRow = NSStackView(views: [volumeLabel, soundsVolumeSlider])
|
||||
volumeRow.orientation = .horizontal
|
||||
volumeRow.spacing = 8
|
||||
|
||||
let stack = NSStackView(views: [inputModeRow, vadRow, inputGainRow, nrCheckbox, stereoMicCheckbox, pttRow, deviceRow,
|
||||
levelRow, auxHeader, auxCheckbox, auxDeviceRow, auxGainRow,
|
||||
notificationsHeader, soundsCheckbox, volumeRow,
|
||||
speechCheckbox, selfTalkCheckbox, pttSoundCheckbox])
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 12
|
||||
stack.alignment = .leading
|
||||
@@ -155,9 +288,35 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
|
||||
vadSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
||||
inputGainSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||
levelMeter.widthAnchor.constraint(equalToConstant: 200),
|
||||
devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||
soundsVolumeSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
||||
auxDevicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||
auxGainSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||
])
|
||||
|
||||
syncNotificationControls()
|
||||
}
|
||||
|
||||
/// Load the notification checkbox/slider states from UserDefaults. Touches EventFeedback.shared
|
||||
/// first so its default values are registered before we read them.
|
||||
private func syncNotificationControls() {
|
||||
let s = FeedbackSettings.current
|
||||
soundsCheckbox.state = s.sounds ? .on : .off
|
||||
speechCheckbox.state = s.speech ? .on : .off
|
||||
selfTalkCheckbox.state = s.selfTalkSounds ? .on : .off
|
||||
pttSoundCheckbox.state = s.pttSound ? .on : .off
|
||||
soundsVolumeSlider.doubleValue = Double(s.volume)
|
||||
}
|
||||
|
||||
@objc private func notificationSettingChanged() {
|
||||
let d = UserDefaults.standard
|
||||
d.set(soundsCheckbox.state == .on, forKey: "feedback.sounds")
|
||||
d.set(speechCheckbox.state == .on, forKey: "feedback.speech")
|
||||
d.set(selfTalkCheckbox.state == .on, forKey: "feedback.selfTalk")
|
||||
d.set(pttSoundCheckbox.state == .on, forKey: "feedback.ptt")
|
||||
d.set(soundsVolumeSlider.doubleValue, forKey: "feedback.volume")
|
||||
}
|
||||
|
||||
// MARK: - Sync from MainWindowController
|
||||
@@ -173,9 +332,22 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
case .alwaysOn: inputModeControl.selectedSegment = 2
|
||||
}
|
||||
|
||||
// Restore the slider from the persisted threshold (invert vadThresholdFromSlider) so a
|
||||
// relaunch shows the saved sensitivity, not the default mid-point.
|
||||
vadSliderValue = vadSliderFromThreshold(mc.vadThresholdValue)
|
||||
vadSlider.doubleValue = vadSliderValue
|
||||
pttKeyLabel.stringValue = "(\(keyCodeName(mc.pttKeyCode)))"
|
||||
|
||||
inputGainSlider.doubleValue = Double(mc.inputGain * 100)
|
||||
updateInputGainLabel()
|
||||
nrCheckbox.state = mc.inputNoiseReduction ? .on : .off
|
||||
stereoMicCheckbox.state = mc.stereoMic ? .on : .off
|
||||
|
||||
auxCheckbox.state = mc.auxEnabled ? .on : .off
|
||||
auxGainSlider.doubleValue = Double(mc.auxGain * 100)
|
||||
updateAuxGainLabel()
|
||||
updateAuxControlsEnabled()
|
||||
|
||||
updateConditionalControls()
|
||||
}
|
||||
|
||||
@@ -213,6 +385,39 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func inputGainChanged() {
|
||||
let gain = Float(inputGainSlider.doubleValue) / 100.0
|
||||
mainController?.inputGain = gain
|
||||
updateInputGainLabel()
|
||||
if let mc = mainController, mc.micStreamId != 0 {
|
||||
client.setInputGain(gain)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func nrChanged() {
|
||||
let on = nrCheckbox.state == .on
|
||||
mainController?.inputNoiseReduction = on
|
||||
if let mc = mainController, mc.micStreamId != 0 {
|
||||
client.setInputNoiseReduction(on)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func stereoMicChanged() {
|
||||
let on = stereoMicCheckbox.state == .on
|
||||
mainController?.stereoMic = on
|
||||
// Channel count only takes effect when the capture device (re)starts, so restart it live.
|
||||
if let mc = mainController, mc.micStreamId != 0 {
|
||||
client.setCaptureChannels(streamId: mc.micStreamId, channels: on ? 2 : 1)
|
||||
client.audioRestart()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateInputGainLabel() {
|
||||
let pct = Int(inputGainSlider.doubleValue.rounded())
|
||||
inputGainValueLabel.stringValue = "\(pct)%"
|
||||
inputGainSlider.setAccessibilityValue("\(pct) percent")
|
||||
}
|
||||
|
||||
@objc private func changePttClicked() {
|
||||
guard let mc = mainController else { return }
|
||||
let sheet = PttKeyCaptureSheet(currentKeyCode: mc.pttKeyCode)
|
||||
@@ -234,6 +439,65 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Aux input stream actions
|
||||
|
||||
@objc private func auxEnabledChanged() {
|
||||
let on = auxCheckbox.state == .on
|
||||
updateAuxControlsEnabled()
|
||||
mainController?.applyAuxEnabled(on)
|
||||
}
|
||||
|
||||
@objc private func refreshAuxDevicesClicked() { loadAuxDevices() }
|
||||
|
||||
@objc private func auxDeviceChanged() {
|
||||
let uid = auxDevicePicker.selectedItem?.representedObject as? String
|
||||
mainController?.applyAuxDevice(uid)
|
||||
}
|
||||
|
||||
@objc private func auxGainChanged() {
|
||||
mainController?.auxGain = Float(auxGainSlider.doubleValue) / 100.0
|
||||
updateAuxGainLabel()
|
||||
}
|
||||
|
||||
private func updateAuxGainLabel() {
|
||||
let pct = Int(auxGainSlider.doubleValue.rounded())
|
||||
auxGainValueLabel.stringValue = "\(pct)%"
|
||||
auxGainSlider.setAccessibilityValue("\(pct) percent")
|
||||
}
|
||||
|
||||
private func updateAuxControlsEnabled() {
|
||||
let on = auxCheckbox.state == .on
|
||||
auxDeviceLabel.isEnabled = on
|
||||
auxDevicePicker.isEnabled = on
|
||||
auxRefreshButton.isEnabled = on
|
||||
auxGainLabel.isEnabled = on
|
||||
auxGainSlider.isEnabled = on
|
||||
auxGainValueLabel.isEnabled = on
|
||||
}
|
||||
|
||||
private func loadAuxDevices() {
|
||||
let devices = InputDeviceEnumerator.list()
|
||||
let prevSelected = (auxDevicePicker.selectedItem?.representedObject as? String)
|
||||
?? mainController?.auxDeviceUID
|
||||
auxDevicePicker.removeAllItems()
|
||||
for d in devices {
|
||||
let item = NSMenuItem(title: d.name, action: nil, keyEquivalent: "")
|
||||
item.representedObject = d.uid
|
||||
auxDevicePicker.menu?.addItem(item)
|
||||
}
|
||||
if let prev = prevSelected,
|
||||
let item = auxDevicePicker.itemArray.first(where: { ($0.representedObject as? String) == prev }) {
|
||||
auxDevicePicker.select(item)
|
||||
} else if let def = devices.first(where: { $0.isDefault }),
|
||||
let item = auxDevicePicker.itemArray.first(where: { ($0.representedObject as? String) == def.uid }) {
|
||||
auxDevicePicker.select(item)
|
||||
} else if auxDevicePicker.numberOfItems > 0 {
|
||||
auxDevicePicker.selectItem(at: 0)
|
||||
}
|
||||
// Persist the resolved selection so a relaunch (or Join Voice) opens the same device.
|
||||
mainController?.auxDeviceUID = auxDevicePicker.selectedItem?.representedObject as? String
|
||||
}
|
||||
|
||||
// MARK: - Level meter (called by MainWindowController)
|
||||
|
||||
func updateLevel(rms: Float) {
|
||||
@@ -284,6 +548,12 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
0.1 * (1.0 - Float(vadSlider.doubleValue - 1.0) / 99.0)
|
||||
}
|
||||
|
||||
/// Inverse of vadThresholdFromSlider: map a stored threshold back to a 1…100 slider position.
|
||||
private func vadSliderFromThreshold(_ threshold: Float) -> Double {
|
||||
let clamped = min(max(threshold, 0.0), 0.1)
|
||||
return Double(1.0 + (1.0 - clamped / 0.1) * 99.0)
|
||||
}
|
||||
|
||||
private func presentSheet(_ vc: NSViewController) {
|
||||
if let cvc = window?.contentViewController {
|
||||
cvc.presentAsSheet(vc)
|
||||
|
||||
@@ -54,7 +54,8 @@ done
|
||||
# ── Resolve VCPKG_ROOT ───────────────────────────────────────────────────────────
|
||||
# The apple-dev CMake cache records the vcpkg root it was configured with (Z_VCPKG_ROOT_DIR);
|
||||
# reuse that so a developer who already configured `cmake --preset dev` doesn't need VCPKG_ROOT
|
||||
# in their shell env to run this script.
|
||||
# in their shell env to run this script. Falls back to the bundled submodule at
|
||||
# <repo-root>/vcpkg if neither the env var nor the cache resolve it.
|
||||
if [[ -z "${VCPKG_ROOT:-}" ]]; then
|
||||
cache="$REPO_ROOT/build/apple-dev/CMakeCache.txt"
|
||||
if [[ -f "$cache" ]]; then
|
||||
@@ -64,9 +65,16 @@ if [[ -z "${VCPKG_ROOT:-}" ]]; then
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [[ -z "${VCPKG_ROOT:-}" ]]; then
|
||||
bundled="$REPO_ROOT/vcpkg"
|
||||
if [[ -f "$bundled/scripts/buildsystems/vcpkg.cmake" ]]; then
|
||||
export VCPKG_ROOT="$bundled"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "${VCPKG_ROOT:-}" || ! -d "$VCPKG_ROOT" ]]; then
|
||||
echo "error: VCPKG_ROOT is not set or does not exist." >&2
|
||||
echo " bootstrap vcpkg (https://vcpkg.io) then: export VCPKG_ROOT=/path/to/vcpkg" >&2
|
||||
echo " either init the bundled submodule: git submodule update --init vcpkg" >&2
|
||||
echo " or point at an external checkout: export VCPKG_ROOT=/path/to/vcpkg" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[build-xcframework] VCPKG_ROOT=$VCPKG_ROOT"
|
||||
@@ -104,14 +112,24 @@ build_slice() {
|
||||
# to build protoc/etc at configure time. Merging those macOS .a files into the iOS
|
||||
# fat library triggers xcodebuild's "binaries with multiple platforms" rejection.
|
||||
local vcpkg_target_lib_dir="$REPO_ROOT/build/$preset/vcpkg_installed/$vcpkg_triplet/lib"
|
||||
local local_lib_dir="$REPO_ROOT/build/$preset/lib"
|
||||
local fat_lib="$REPO_ROOT/build/$preset/lib/libvoicecat-fat.a"
|
||||
echo "[build-xcframework] $slice_name: merging vcpkg deps into fat static lib (triplet: $vcpkg_triplet)"
|
||||
# Collect all .a files (libvoicecat.a + every vcpkg target .a). libtool -static concatenates
|
||||
# object files from all input archives; duplicate-object warnings are benign (the linker
|
||||
# resolves duplicates at final link time). "no symbols" warnings are for empty AVX2/AVX512
|
||||
# objects on arm64 — also benign.
|
||||
echo "[build-xcframework] $slice_name: merging vcpkg + vendored deps into fat static lib (triplet: $vcpkg_triplet)"
|
||||
# Collect all .a files (libvoicecat.a + every vcpkg target .a + locally-built vendored static
|
||||
# libs). libtool -static concatenates object files from all input archives; duplicate-object
|
||||
# warnings are benign (the linker resolves duplicates at final link time). "no symbols"
|
||||
# warnings are for empty AVX2/AVX512 objects on arm64 — also benign.
|
||||
#
|
||||
# Two source dirs:
|
||||
# - vcpkg_target_lib_dir: vcpkg-installed deps (protobuf, mbedtls, sodium, opus, …).
|
||||
# - local_lib_dir: CMake static-lib targets built in this tree that are NOT vcpkg deps —
|
||||
# e.g. the vendored RNNoise lib (third_party/rnnoise, VOICECAT_HAS_NS). Without it the
|
||||
# fat lib references _rnnoise_* symbols that nothing defines → undefined-symbol link
|
||||
# errors in Xcode. Exclude libvoicecat* (the main lib is $lib; libvoicecat-fat.a is the
|
||||
# output we're building here).
|
||||
local all_libs=( "$lib" )
|
||||
while IFS= read -r f; do all_libs+=( "$f" ); done < <(find "$vcpkg_target_lib_dir" -name '*.a' -not -name 'libvoicecat*' | sort)
|
||||
while IFS= read -r f; do all_libs+=( "$f" ); done < <(find "$local_lib_dir" -maxdepth 1 -name '*.a' -not -name 'libvoicecat*' | sort)
|
||||
libtool -static -o "$fat_lib" "${all_libs[@]}" 2>&1 | grep -v 'has no symbols' || true
|
||||
echo "[build-xcframework] $slice_name -> $fat_lib ($(stat -f%z "$fat_lib") bytes, fat)"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace VoiceCat.App.Audio;
|
||||
|
||||
// ── Scope types (mirror macOS ScreenAudioScope / ScreenAudioSelection) ────────
|
||||
|
||||
public abstract record AppAudioScope;
|
||||
// ExcludeSelf=true captures the whole system render mix minus VoiceCat's own process tree
|
||||
// (kills the self-echo loop). Routed through the external-feed mixer as a single EXCLUDE
|
||||
// capture; ExcludeSelf=false keeps the core's whole-device loopback path.
|
||||
public sealed record EntireDesktop(bool ExcludeSelf = false) : AppAudioScope;
|
||||
public sealed record OnlyApps(IReadOnlyList<int> Pids, IReadOnlyList<string> Names) : AppAudioScope;
|
||||
public sealed record AllExceptApps(IReadOnlyList<int> Pids, IReadOnlyList<string> Names) : AppAudioScope;
|
||||
|
||||
// IsPlaying = a process in this app's executable group currently has an *active* audio
|
||||
// session on some render endpoint. Purely informational for the picker — capture works
|
||||
// on any PID regardless (process-loopback yields silence until the app plays).
|
||||
public sealed record AudioAppInfo(int Pid, string DisplayName, bool IsPlaying);
|
||||
|
||||
// ── Enumerator ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public static class AudioSessionEnumerator
|
||||
{
|
||||
// Returns every app in the user's interactive session — windowed or not — so any of
|
||||
// them can be picked for capture even before it starts producing audio. Process
|
||||
// loopback (see ProcessLoopbackCapture) targets a PID and its child tree, so a silent
|
||||
// selection simply starts working the moment that app plays.
|
||||
//
|
||||
// Apps are deduped by executable (multiple PIDs of the same program collapse to one
|
||||
// row whose PID is the process-tree root). Session-0 services and VoiceCat itself are
|
||||
// excluded. Apps currently producing audio are flagged IsPlaying and sorted first.
|
||||
public static IReadOnlyList<AudioAppInfo> GetAudioApps()
|
||||
{
|
||||
int selfPid = Environment.ProcessId;
|
||||
int sessionId = SafeCurrentSessionId();
|
||||
var playingPids = GetActiveAudioPids();
|
||||
|
||||
// Group by executable name; keep the best representative PID per group.
|
||||
var groups = new Dictionary<string, AppGroup>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var proc in Process.GetProcesses())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (proc.Id == selfPid) continue;
|
||||
if (proc.SessionId != sessionId) continue; // drop session-0 services
|
||||
string name = proc.ProcessName;
|
||||
if (string.IsNullOrEmpty(name)) continue;
|
||||
|
||||
bool hasWindow = proc.MainWindowHandle != IntPtr.Zero;
|
||||
bool playing = playingPids.Contains(proc.Id);
|
||||
string title = hasWindow ? SafeWindowTitle(proc) : "";
|
||||
string display = title.Length > 0 ? $"{name} — {title}" : name;
|
||||
|
||||
if (groups.TryGetValue(name, out var g))
|
||||
{
|
||||
g.AnyPlaying |= playing;
|
||||
// Prefer a windowed PID (the process-tree root) as the capture target;
|
||||
// among non-windowed, prefer a currently-playing PID.
|
||||
bool better = (hasWindow && !g.HasWindow)
|
||||
|| (!g.HasWindow && !hasWindow && playing && !g.RepPlaying);
|
||||
if (better)
|
||||
{
|
||||
g.Pid = proc.Id;
|
||||
g.Display = display;
|
||||
g.HasWindow = hasWindow;
|
||||
g.RepPlaying = playing;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
groups[name] = new AppGroup
|
||||
{
|
||||
Pid = proc.Id,
|
||||
Display = display,
|
||||
HasWindow = hasWindow,
|
||||
RepPlaying = playing,
|
||||
AnyPlaying = playing,
|
||||
};
|
||||
}
|
||||
}
|
||||
catch { /* protected/exited process — skip */ }
|
||||
finally { proc.Dispose(); }
|
||||
}
|
||||
|
||||
return groups.Values
|
||||
.Select(g => new AudioAppInfo(g.Pid, g.Display, g.AnyPlaying))
|
||||
.OrderByDescending(a => a.IsPlaying)
|
||||
.ThenBy(a => a.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed class AppGroup
|
||||
{
|
||||
public int Pid;
|
||||
public string Display = "";
|
||||
public bool HasWindow;
|
||||
public bool RepPlaying; // representative PID is playing
|
||||
public bool AnyPlaying; // any PID in the group is playing
|
||||
}
|
||||
|
||||
private static int SafeCurrentSessionId()
|
||||
{
|
||||
try { using var me = Process.GetCurrentProcess(); return me.SessionId; }
|
||||
catch { return 1; } // typical interactive session fallback
|
||||
}
|
||||
|
||||
private static string SafeWindowTitle(Process proc)
|
||||
{
|
||||
try { return proc.MainWindowTitle; }
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
// ── WASAPI: PIDs with an active render session (any endpoint) ─────────────
|
||||
// Scans ALL active render endpoints, not just the default — an app routed to a
|
||||
// secondary device still counts as "playing now".
|
||||
|
||||
private static HashSet<int> GetActiveAudioPids()
|
||||
{
|
||||
var pids = new HashSet<int>();
|
||||
IMMDeviceEnumerator? enumerator = null;
|
||||
IMMDeviceCollection? devices = null;
|
||||
|
||||
try
|
||||
{
|
||||
enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(
|
||||
Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!;
|
||||
|
||||
if (enumerator.EnumAudioEndpoints(0 /*eRender*/, 0x1 /*DEVICE_STATE_ACTIVE*/,
|
||||
out devices) < 0 || devices == null)
|
||||
return pids;
|
||||
|
||||
devices.GetCount(out int devCount);
|
||||
for (int d = 0; d < devCount; d++)
|
||||
CollectActivePids(devices, d, pids);
|
||||
}
|
||||
catch { /* no audio device or WASAPI unavailable — ignore */ }
|
||||
finally
|
||||
{
|
||||
if (devices != null) Marshal.ReleaseComObject(devices);
|
||||
if (enumerator != null) Marshal.ReleaseComObject(enumerator);
|
||||
}
|
||||
|
||||
return pids;
|
||||
}
|
||||
|
||||
private static void CollectActivePids(IMMDeviceCollection devices, int index, HashSet<int> pids)
|
||||
{
|
||||
IMMDevice? device = null;
|
||||
IAudioSessionManager2? manager = null;
|
||||
IAudioSessionEnumerator? sessions = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (devices.Item(index, out device) < 0 || device == null) return;
|
||||
|
||||
var mgr2Iid = new Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F");
|
||||
if (device.Activate(ref mgr2Iid, 0x17 /*CLSCTX_ALL*/, IntPtr.Zero, out object mgr) < 0)
|
||||
return;
|
||||
manager = (IAudioSessionManager2)mgr;
|
||||
|
||||
if (manager.GetSessionEnumerator(out sessions) < 0 || sessions == null) return;
|
||||
sessions.GetCount(out int count);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
IAudioSessionControl? ctrl = null;
|
||||
try
|
||||
{
|
||||
if (sessions.GetSession(i, out ctrl) < 0 || ctrl == null) continue;
|
||||
ctrl.GetState(out int state);
|
||||
if (state != 1 /*AudioSessionStateActive*/) continue;
|
||||
|
||||
var ctrl2 = (IAudioSessionControl2)ctrl;
|
||||
ctrl2.GetProcessId(out uint pid);
|
||||
if (pid != 0) pids.Add((int)pid);
|
||||
}
|
||||
catch { /* stale session */ }
|
||||
finally { if (ctrl != null) Marshal.ReleaseComObject(ctrl); }
|
||||
}
|
||||
}
|
||||
catch { /* device went away */ }
|
||||
finally
|
||||
{
|
||||
if (sessions != null) Marshal.ReleaseComObject(sessions);
|
||||
if (manager != null) Marshal.ReleaseComObject(manager);
|
||||
if (device != null) Marshal.ReleaseComObject(device);
|
||||
}
|
||||
}
|
||||
|
||||
// ── COM interface declarations ─────────────────────────────────────────────
|
||||
|
||||
[ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMMDeviceEnumerator
|
||||
{
|
||||
[PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IMMDeviceCollection devices);
|
||||
[PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
|
||||
[PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device);
|
||||
[PreserveSig] int RegisterEndpointNotificationCallback(IntPtr client);
|
||||
[PreserveSig] int UnregisterEndpointNotificationCallback(IntPtr client);
|
||||
}
|
||||
|
||||
[ComImport, Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMMDeviceCollection
|
||||
{
|
||||
[PreserveSig] int GetCount(out int count);
|
||||
[PreserveSig] int Item(int index, out IMMDevice device);
|
||||
}
|
||||
|
||||
[ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMMDevice
|
||||
{
|
||||
[PreserveSig] int Activate(ref Guid iid, int clsCtx, IntPtr activationParams,
|
||||
[MarshalAs(UnmanagedType.IUnknown)] out object ppInterface);
|
||||
[PreserveSig] int OpenPropertyStore(int stgmAccess, out IntPtr propStore);
|
||||
[PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id);
|
||||
[PreserveSig] int GetState(out int state);
|
||||
}
|
||||
|
||||
[ComImport, Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IAudioSessionManager2
|
||||
{
|
||||
[PreserveSig] int GetAudioSessionControl(ref Guid audioSessionGuid, int streamFlags,
|
||||
out IAudioSessionControl session);
|
||||
[PreserveSig] int GetSimpleAudioVolume(ref Guid audioSessionGuid, int streamFlags,
|
||||
out IntPtr audioVolume);
|
||||
[PreserveSig] int GetSessionEnumerator(out IAudioSessionEnumerator sessionEnum);
|
||||
[PreserveSig] int RegisterSessionNotification(IntPtr notification);
|
||||
[PreserveSig] int UnregisterSessionNotification(IntPtr notification);
|
||||
[PreserveSig] int RegisterDuckNotification([MarshalAs(UnmanagedType.LPWStr)] string sessionID,
|
||||
IntPtr notification);
|
||||
[PreserveSig] int UnregisterDuckNotification(IntPtr notification);
|
||||
}
|
||||
|
||||
[ComImport, Guid("E2F5BB11-0570-40CA-ACDD-3AA01277DEE8"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IAudioSessionEnumerator
|
||||
{
|
||||
[PreserveSig] int GetCount(out int sessionCount);
|
||||
[PreserveSig] int GetSession(int sessionIndex, out IAudioSessionControl session);
|
||||
}
|
||||
|
||||
[ComImport, Guid("F4B1A599-7266-4319-A8CA-E70ACB11E8CD"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IAudioSessionControl
|
||||
{
|
||||
[PreserveSig] int GetState(out int state);
|
||||
[PreserveSig] int GetDisplayName([MarshalAs(UnmanagedType.LPWStr)] out string name);
|
||||
[PreserveSig] int SetDisplayName([MarshalAs(UnmanagedType.LPWStr)] string name,
|
||||
ref Guid eventContext);
|
||||
[PreserveSig] int GetIconPath([MarshalAs(UnmanagedType.LPWStr)] out string iconPath);
|
||||
[PreserveSig] int SetIconPath([MarshalAs(UnmanagedType.LPWStr)] string iconPath,
|
||||
ref Guid eventContext);
|
||||
[PreserveSig] int GetGroupingParam(out Guid groupingParam);
|
||||
[PreserveSig] int SetGroupingParam(ref Guid groupingParam, ref Guid eventContext);
|
||||
[PreserveSig] int RegisterAudioSessionNotification(IntPtr notification);
|
||||
[PreserveSig] int UnregisterAudioSessionNotification(IntPtr notification);
|
||||
}
|
||||
|
||||
[ComImport, Guid("BFB7FF88-7239-4FC9-8FA2-07C950BE9C6D"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IAudioSessionControl2 : IAudioSessionControl
|
||||
{
|
||||
[PreserveSig] int GetSessionIdentifier([MarshalAs(UnmanagedType.LPWStr)] out string id);
|
||||
[PreserveSig] int GetSessionInstanceIdentifier([MarshalAs(UnmanagedType.LPWStr)] out string id);
|
||||
[PreserveSig] int GetProcessId(out uint pid);
|
||||
[PreserveSig] int IsSystemSoundsSession();
|
||||
[PreserveSig] int SetDuckingPreference(bool optOut);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// Captures a second hardware input for AUX_DEVICE and feeds it through vc_stream_feed_pcm.
|
||||
// Endpoint identifiers are WASAPI-specific and cannot be exchanged with the core's miniaudio ids.
|
||||
namespace VoiceCat.App.Audio;
|
||||
|
||||
/// <summary>An audio input (capture) endpoint for the aux-stream device picker. <see cref="Id"/>
|
||||
/// is a WASAPI endpoint id (round-trip only; never construct by hand) — pass null to capture the
|
||||
/// system default. <see cref="ToString"/> returns the friendly name for ComboBox display.</summary>
|
||||
public sealed record InputDeviceInfo(string Id, string Name, bool IsDefault)
|
||||
{
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
/// <summary>Enumerates WASAPI capture endpoints. Separate from the core's vc_list_devices because
|
||||
/// the aux device is opened client-side and needs a WASAPI id, not a miniaudio one.</summary>
|
||||
public static class InputDeviceEnumerator
|
||||
{
|
||||
public static IReadOnlyList<InputDeviceInfo> List()
|
||||
{
|
||||
var result = new List<InputDeviceInfo>();
|
||||
InputDeviceCapture.IMMDeviceEnumerator? enumerator = null;
|
||||
IntPtr collectionPtr = IntPtr.Zero;
|
||||
string? defaultId = null;
|
||||
|
||||
try
|
||||
{
|
||||
enumerator = (InputDeviceCapture.IMMDeviceEnumerator)Activator.CreateInstance(
|
||||
Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!;
|
||||
|
||||
// Resolve the default capture endpoint id so the picker can flag it.
|
||||
if (enumerator.GetDefaultAudioEndpoint(1 /*eCapture*/, 0 /*eConsole*/,
|
||||
out var defDev) == 0 && defDev != null)
|
||||
{
|
||||
try { if (defDev.GetId(out string id) == 0) defaultId = id; }
|
||||
finally { Marshal.ReleaseComObject(defDev); }
|
||||
}
|
||||
|
||||
// DEVICE_STATE_ACTIVE = 0x1 — only currently-usable endpoints.
|
||||
if (enumerator.EnumAudioEndpoints(1 /*eCapture*/, 0x1, out collectionPtr) != 0
|
||||
|| collectionPtr == IntPtr.Zero)
|
||||
return result;
|
||||
|
||||
var collection = (InputDeviceCapture.IMMDeviceCollection)
|
||||
Marshal.GetObjectForIUnknown(collectionPtr);
|
||||
collection.GetCount(out int count);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (collection.Item(i, out var dev) != 0 || dev == null) continue;
|
||||
try
|
||||
{
|
||||
if (dev.GetId(out string id) != 0) continue;
|
||||
string name = ReadFriendlyName(dev) ?? "Unknown input device";
|
||||
result.Add(new InputDeviceInfo(id, name, id == defaultId));
|
||||
}
|
||||
finally { Marshal.ReleaseComObject(dev); }
|
||||
}
|
||||
}
|
||||
catch { /* no audio subsystem / WASAPI unavailable — return what we have */ }
|
||||
finally
|
||||
{
|
||||
if (collectionPtr != IntPtr.Zero) Marshal.Release(collectionPtr);
|
||||
if (enumerator != null) Marshal.ReleaseComObject(enumerator);
|
||||
}
|
||||
|
||||
return result.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static string? ReadFriendlyName(InputDeviceCapture.IMMDevice dev)
|
||||
{
|
||||
if (dev.OpenPropertyStore(0 /*STGM_READ*/, out IntPtr storePtr) != 0
|
||||
|| storePtr == IntPtr.Zero)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
var store = (InputDeviceCapture.IPropertyStore)Marshal.GetObjectForIUnknown(storePtr);
|
||||
// PKEY_Device_FriendlyName = {a45c254e-df1c-4efd-8020-67d146a850e0}, pid 14.
|
||||
var key = new InputDeviceCapture.PropertyKey
|
||||
{
|
||||
fmtid = new Guid("a45c254e-df1c-4efd-8020-67d146a850e0"),
|
||||
pid = 14,
|
||||
};
|
||||
if (store.GetValue(ref key, out var pv) != 0) return null;
|
||||
try
|
||||
{
|
||||
// VT_LPWSTR = 31.
|
||||
return pv.vt == 31 ? Marshal.PtrToStringUni(pv.pointerValue) : null;
|
||||
}
|
||||
finally { InputDeviceCapture.PropVariantClear(ref pv); }
|
||||
}
|
||||
finally { Marshal.Release(storePtr); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Captures a single hardware input device in WASAPI shared mode and raises a 20 ms
|
||||
/// (960 samples/channel @ 48 kHz, interleaved s16) frame event. The caller feeds these to the
|
||||
/// core's external-feed stream. Threading mirrors ProcessLoopbackCapture: all WASAPI work runs on
|
||||
/// a dedicated background (MTA) thread; the event fires on that thread.</summary>
|
||||
public sealed class InputDeviceCapture : IDisposable
|
||||
{
|
||||
/// <summary>Fired on the capture thread every 20 ms: (interleaved s16 PCM, samplesPerChannel
|
||||
/// = 960, channels).</summary>
|
||||
public event Action<short[], int /*samplesPerChannel*/, int /*channels*/>? PcmFrameReady;
|
||||
|
||||
private const int SampleRate = 48000;
|
||||
private const int FrameSamples = 960; // 20 ms
|
||||
|
||||
private readonly string? _deviceId; // null = system default capture endpoint
|
||||
|
||||
private IAudioClient? _audioClient;
|
||||
private IAudioCaptureClient? _captureClient;
|
||||
|
||||
private AutoResetEvent? _bufferEvent;
|
||||
private Thread? _captureThread;
|
||||
private volatile bool _running;
|
||||
private int _channels;
|
||||
|
||||
// Accumulator: assembles driver-callback-sized fragments into FrameSamples chunks.
|
||||
private short[] _accumBuf = [];
|
||||
private int _accumCount;
|
||||
|
||||
// Init-done signal: Set() by the capture thread after activation completes.
|
||||
private readonly ManualResetEventSlim _initDone = new(false);
|
||||
private bool _initOk;
|
||||
|
||||
public InputDeviceCapture(string? deviceId) => _deviceId = deviceId;
|
||||
|
||||
/// <summary>Starts capture. Blocks until WASAPI activation completes (typically <100 ms).
|
||||
/// Returns false if the device cannot be opened.</summary>
|
||||
public bool Start()
|
||||
{
|
||||
if (_running) return false;
|
||||
_running = true;
|
||||
_captureThread = new Thread(CaptureThreadProc)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "AuxInputCapture",
|
||||
};
|
||||
_captureThread.Start();
|
||||
|
||||
bool ok = _initDone.Wait(5000) && _initOk;
|
||||
if (!ok) _running = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_running = false;
|
||||
_bufferEvent?.Set();
|
||||
_captureThread?.Join(500);
|
||||
try { _audioClient?.Stop(); } catch { /* device already gone */ }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
if (_captureClient != null) { Marshal.ReleaseComObject(_captureClient); _captureClient = null; }
|
||||
if (_audioClient != null) { Marshal.ReleaseComObject(_audioClient); _audioClient = null; }
|
||||
_bufferEvent?.Dispose();
|
||||
_initDone.Dispose();
|
||||
}
|
||||
|
||||
// ── Capture thread (MTA) ──────────────────────────────────────────────────
|
||||
|
||||
private void CaptureThreadProc()
|
||||
{
|
||||
_initOk = ActivateAndStart();
|
||||
_initDone.Set();
|
||||
if (!_initOk) return;
|
||||
CaptureLoop();
|
||||
}
|
||||
|
||||
private bool ActivateAndStart()
|
||||
{
|
||||
if (!ActivateClient()) return false;
|
||||
|
||||
_bufferEvent = new AutoResetEvent(false);
|
||||
if (_audioClient!.SetEventHandle(_bufferEvent.SafeWaitHandle.DangerousGetHandle()) < 0)
|
||||
return false;
|
||||
|
||||
return _audioClient.Start() >= 0;
|
||||
}
|
||||
|
||||
private bool ActivateClient()
|
||||
{
|
||||
IMMDeviceEnumerator? enumerator = null;
|
||||
IMMDevice? device = null;
|
||||
try
|
||||
{
|
||||
enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(
|
||||
Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!;
|
||||
|
||||
int hr = _deviceId is null
|
||||
? enumerator.GetDefaultAudioEndpoint(1 /*eCapture*/, 0 /*eConsole*/, out device)
|
||||
: enumerator.GetDevice(_deviceId, out device);
|
||||
if (hr != 0 || device == null) return false;
|
||||
|
||||
var iidAudioClient = new Guid("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2");
|
||||
if (device.Activate(ref iidAudioClient, 0x17 /*CLSCTX_ALL*/, IntPtr.Zero,
|
||||
out object acObj) != 0 || acObj is not IAudioClient ac)
|
||||
return false;
|
||||
_audioClient = ac;
|
||||
|
||||
return InitializeStream();
|
||||
}
|
||||
catch { return false; }
|
||||
finally
|
||||
{
|
||||
if (device != null) Marshal.ReleaseComObject(device);
|
||||
if (enumerator != null) Marshal.ReleaseComObject(enumerator);
|
||||
}
|
||||
}
|
||||
|
||||
private bool InitializeStream()
|
||||
{
|
||||
// Try s16 stereo first; fall back to s16 mono. AUTOCONVERTPCM lets the audio engine
|
||||
// resample/convert the device's native format to our requested 48 kHz s16; EVENTCALLBACK
|
||||
// drives the buffer-ready event. Shared mode (0), no LOOPBACK (this is a capture device).
|
||||
// AUDCLNT_STREAMFLAGS_EVENTCALLBACK = 0x00040000
|
||||
// AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM = 0x80000000
|
||||
// AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY = 0x08000000
|
||||
const uint streamFlags = 0x00040000u | 0x80000000u | 0x08000000u;
|
||||
|
||||
foreach (int ch in new[] { 2, 1 })
|
||||
{
|
||||
var fmt = new WaveFormatEx
|
||||
{
|
||||
wFormatTag = 1, // WAVE_FORMAT_PCM
|
||||
nChannels = (ushort)ch,
|
||||
nSamplesPerSec = SampleRate,
|
||||
wBitsPerSample = 16,
|
||||
nBlockAlign = (ushort)(ch * 2),
|
||||
nAvgBytesPerSec = (uint)(SampleRate * ch * 2),
|
||||
cbSize = 0,
|
||||
};
|
||||
|
||||
IntPtr pFmt = Marshal.AllocHGlobal(Marshal.SizeOf<WaveFormatEx>());
|
||||
try
|
||||
{
|
||||
Marshal.StructureToPtr(fmt, pFmt, false);
|
||||
int hr = _audioClient!.Initialize(0 /*AUDCLNT_SHAREMODE_SHARED*/, streamFlags,
|
||||
2_000_000 /*200 ms hns*/, 0, pFmt, IntPtr.Zero);
|
||||
if (hr >= 0)
|
||||
{
|
||||
_channels = ch;
|
||||
_accumBuf = new short[FrameSamples * ch];
|
||||
_accumCount = 0;
|
||||
|
||||
var iidCapture = new Guid("C8ADBD64-E71E-48a0-A4DE-185C395CD317");
|
||||
if (_audioClient.GetService(ref iidCapture, out object ccObj) != 0
|
||||
|| ccObj is not IAudioCaptureClient cc)
|
||||
return false;
|
||||
_captureClient = cc;
|
||||
return true;
|
||||
}
|
||||
if (ch == 1) return false;
|
||||
}
|
||||
finally { Marshal.FreeHGlobal(pFmt); }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Capture loop ─────────────────────────────────────────────────────────
|
||||
|
||||
private void CaptureLoop()
|
||||
{
|
||||
while (_running)
|
||||
{
|
||||
_bufferEvent!.WaitOne(100);
|
||||
if (!_running) break;
|
||||
|
||||
while (_running)
|
||||
{
|
||||
if (_captureClient!.GetNextPacketSize(out uint packetSize) < 0 || packetSize == 0)
|
||||
break;
|
||||
|
||||
if (_captureClient.GetBuffer(out IntPtr dataPtr, out uint framesAvailable,
|
||||
out uint flags, out _, out _) < 0)
|
||||
break;
|
||||
|
||||
bool silent = (flags & 2) != 0; // AUDCLNT_BUFFERFLAGS_SILENT
|
||||
if (framesAvailable > 0)
|
||||
{
|
||||
if (silent) AccumulateSilence((int)framesAvailable);
|
||||
else AccumulatePcm(dataPtr, (int)framesAvailable);
|
||||
}
|
||||
|
||||
_captureClient.ReleaseBuffer(framesAvailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void AccumulatePcm(IntPtr data, int frames)
|
||||
{
|
||||
var src = (short*)data.ToPointer();
|
||||
int total = frames * _channels;
|
||||
int idx = 0;
|
||||
while (idx < total)
|
||||
{
|
||||
int space = _accumBuf.Length - _accumCount;
|
||||
int copy = Math.Min(total - idx, space);
|
||||
fixed (short* dst = _accumBuf)
|
||||
Buffer.MemoryCopy(src + idx, dst + _accumCount, copy * 2L, copy * 2L);
|
||||
_accumCount += copy;
|
||||
idx += copy;
|
||||
if (_accumCount == _accumBuf.Length)
|
||||
FlushFrame();
|
||||
}
|
||||
}
|
||||
|
||||
private void AccumulateSilence(int frames)
|
||||
{
|
||||
int total = frames * _channels;
|
||||
int idx = 0;
|
||||
while (idx < total)
|
||||
{
|
||||
int space = _accumBuf.Length - _accumCount;
|
||||
int fill = Math.Min(total - idx, space);
|
||||
Array.Clear(_accumBuf, _accumCount, fill);
|
||||
_accumCount += fill;
|
||||
idx += fill;
|
||||
if (_accumCount == _accumBuf.Length)
|
||||
FlushFrame();
|
||||
}
|
||||
}
|
||||
|
||||
private void FlushFrame()
|
||||
{
|
||||
var copy = new short[_accumBuf.Length];
|
||||
_accumBuf.AsSpan().CopyTo(copy);
|
||||
PcmFrameReady?.Invoke(copy, FrameSamples, _channels);
|
||||
_accumCount = 0;
|
||||
}
|
||||
|
||||
// ── COM declarations ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Declared internal here so InputDeviceEnumerator can share them. These are standard MMDevice
|
||||
// / WASAPI interfaces; a normal capture endpoint honours QueryInterface, so RCW marshalling is
|
||||
// safe (unlike ProcessLoopbackCapture's process-loopback objects, which need raw vtable calls).
|
||||
|
||||
[ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMMDeviceEnumerator
|
||||
{
|
||||
[PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IntPtr devices);
|
||||
[PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
|
||||
[PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device);
|
||||
[PreserveSig] int RegisterEndpointNotificationCallback(IntPtr client);
|
||||
[PreserveSig] int UnregisterEndpointNotificationCallback(IntPtr client);
|
||||
}
|
||||
|
||||
[ComImport, Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMMDeviceCollection
|
||||
{
|
||||
[PreserveSig] int GetCount(out int count);
|
||||
[PreserveSig] int Item(int index, out IMMDevice device);
|
||||
}
|
||||
|
||||
[ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMMDevice
|
||||
{
|
||||
[PreserveSig] int Activate(ref Guid iid, int clsCtx, IntPtr activationParams,
|
||||
[MarshalAs(UnmanagedType.IUnknown)] out object ppInterface);
|
||||
[PreserveSig] int OpenPropertyStore(int stgmAccess, out IntPtr propStore);
|
||||
[PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id);
|
||||
[PreserveSig] int GetState(out int state);
|
||||
}
|
||||
|
||||
[ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IPropertyStore
|
||||
{
|
||||
[PreserveSig] int GetCount(out int count);
|
||||
[PreserveSig] int GetAt(int index, out PropertyKey key);
|
||||
[PreserveSig] int GetValue(ref PropertyKey key, out PropVariant value);
|
||||
[PreserveSig] int SetValue(ref PropertyKey key, ref PropVariant value);
|
||||
[PreserveSig] int Commit();
|
||||
}
|
||||
|
||||
[ComImport, Guid("1CB9AD4C-DBFA-4C32-B178-C2F568A703B2"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IAudioClient
|
||||
{
|
||||
[PreserveSig] int Initialize(int shareMode, uint streamFlags, long hnsBufferDuration,
|
||||
long hnsPeriodicity, IntPtr pFormat, IntPtr audioSessionGuid);
|
||||
[PreserveSig] int GetBufferSize(out uint numBufferFrames);
|
||||
[PreserveSig] int GetStreamLatency(out long latency);
|
||||
[PreserveSig] int GetCurrentPadding(out uint numPaddingFrames);
|
||||
[PreserveSig] int IsFormatSupported(int shareMode, IntPtr pFormat, out IntPtr closestMatch);
|
||||
[PreserveSig] int GetMixFormat(out IntPtr deviceFormat);
|
||||
[PreserveSig] int GetDevicePeriod(out long defaultDevicePeriod, out long minimumDevicePeriod);
|
||||
[PreserveSig] int Start();
|
||||
[PreserveSig] int Stop();
|
||||
[PreserveSig] int Reset();
|
||||
[PreserveSig] int SetEventHandle(IntPtr eventHandle);
|
||||
[PreserveSig] int GetService(ref Guid riid,
|
||||
[MarshalAs(UnmanagedType.IUnknown)] out object ppv);
|
||||
}
|
||||
|
||||
[ComImport, Guid("C8ADBD64-E71E-48A0-A4DE-185C395CD317"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IAudioCaptureClient
|
||||
{
|
||||
[PreserveSig] int GetBuffer(out IntPtr data, out uint numFramesToRead, out uint flags,
|
||||
out ulong devicePosition, out ulong qpcPosition);
|
||||
[PreserveSig] int ReleaseBuffer(uint numFramesRead);
|
||||
[PreserveSig] int GetNextPacketSize(out uint numFramesInNextPacket);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 2)]
|
||||
private struct WaveFormatEx
|
||||
{
|
||||
public ushort wFormatTag;
|
||||
public ushort nChannels;
|
||||
public uint nSamplesPerSec;
|
||||
public uint nAvgBytesPerSec;
|
||||
public ushort nBlockAlign;
|
||||
public ushort wBitsPerSample;
|
||||
public ushort cbSize;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct PropertyKey
|
||||
{
|
||||
public Guid fmtid;
|
||||
public int pid;
|
||||
}
|
||||
|
||||
// Minimal PROPVARIANT: we only ever read VT_LPWSTR (friendly name). x64 layout — the value
|
||||
// union starts at offset 8 after vt(2)+reserved(6).
|
||||
[StructLayout(LayoutKind.Explicit)]
|
||||
internal struct PropVariant
|
||||
{
|
||||
[FieldOffset(0)] public ushort vt;
|
||||
[FieldOffset(8)] public IntPtr pointerValue;
|
||||
}
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
internal static extern int PropVariantClear(ref PropVariant pvar);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using VoiceCat.Interop;
|
||||
|
||||
// Owns N ProcessLoopbackCapture instances, mixes their PCM every 20 ms, and feeds
|
||||
// the result to the core via vc_stream_feed_pcm. Used for per-app audio sharing.
|
||||
namespace VoiceCat.App.Audio;
|
||||
|
||||
public sealed class ProcessAudioMixer : IDisposable
|
||||
{
|
||||
private const int SampleRate = 48000;
|
||||
private const int FrameSamples = 960;
|
||||
private const int Channels = 2; // stereo; captures fall back to mono if needed
|
||||
|
||||
private readonly List<ProcessLoopbackCapture> _captures = [];
|
||||
|
||||
// Per-capture latest frame, protected by _frameLock.
|
||||
private readonly object _frameLock = new();
|
||||
private List<short[]> _latestFrames = [];
|
||||
private int _activeChannels = Channels;
|
||||
|
||||
private Thread? _mixThread;
|
||||
private volatile bool _running;
|
||||
private VoiceCatClient? _client;
|
||||
private uint _streamId;
|
||||
|
||||
public void Start(AppAudioScope scope, VoiceCatClient client, uint streamId)
|
||||
{
|
||||
if (_running) return;
|
||||
_client = client;
|
||||
_streamId = streamId;
|
||||
|
||||
var specs = ResolveCaptures(scope);
|
||||
if (specs.Count == 0)
|
||||
{
|
||||
// nothing to capture — scope resolved to empty set
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_frameLock)
|
||||
{
|
||||
_latestFrames = new List<short[]>(new short[specs.Count][]);
|
||||
_activeChannels = Channels;
|
||||
}
|
||||
|
||||
for (int i = 0; i < specs.Count; i++)
|
||||
{
|
||||
int captureIndex = i;
|
||||
var (pid, mode) = specs[i];
|
||||
var cap = new ProcessLoopbackCapture(pid, mode);
|
||||
cap.PcmFrameReady += (pcm, spc, ch) => OnCaptureFrame(captureIndex, pcm, ch);
|
||||
_captures.Add(cap);
|
||||
}
|
||||
|
||||
foreach (var c in _captures) c.Start();
|
||||
|
||||
_running = true;
|
||||
_mixThread = new Thread(MixLoop) { IsBackground = true, Name = "ProcessAudioMixer" };
|
||||
_mixThread.Start();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_running = false;
|
||||
_mixThread?.Join(500);
|
||||
foreach (var c in _captures) { c.Stop(); c.Dispose(); }
|
||||
_captures.Clear();
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
// ── Capture callback ──────────────────────────────────────────────────────
|
||||
|
||||
private void OnCaptureFrame(int index, short[] pcm, int channels)
|
||||
{
|
||||
lock (_frameLock)
|
||||
{
|
||||
// Upmix mono → stereo interleave if the capture fell back to mono.
|
||||
if (channels == 1 && _activeChannels == 2)
|
||||
pcm = MonoToStereo(pcm);
|
||||
if (index < _latestFrames.Count)
|
||||
_latestFrames[index] = pcm;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mix loop (20 ms timer) ────────────────────────────────────────────────
|
||||
|
||||
private void MixLoop()
|
||||
{
|
||||
// Use a target period close to 20 ms; small under-shoot avoids accumulating drift.
|
||||
const int periodMs = 19;
|
||||
while (_running)
|
||||
{
|
||||
Thread.Sleep(periodMs);
|
||||
if (!_running) break;
|
||||
|
||||
short[] mix;
|
||||
lock (_frameLock)
|
||||
{
|
||||
int len = FrameSamples * _activeChannels;
|
||||
mix = new short[len];
|
||||
|
||||
foreach (var frame in _latestFrames)
|
||||
{
|
||||
if (frame == null) continue;
|
||||
int frameLen = Math.Min(frame.Length, len);
|
||||
for (int i = 0; i < frameLen; i++)
|
||||
{
|
||||
int sum = mix[i] + frame[i];
|
||||
mix[i] = (short)Math.Clamp(sum, short.MinValue, short.MaxValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_client?.StreamFeedPcm(_streamId, mix, FrameSamples, (uint)_activeChannels);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Resolve the scope to the WASAPI captures to open:
|
||||
// OnlyApps → one INCLUDE capture per selected process tree.
|
||||
// AllExceptApps → one EXCLUDE capture of the single selected process tree, which
|
||||
// natively captures the whole system render mix minus that tree
|
||||
// (dynamic — apps launched later are included automatically).
|
||||
private static List<(int pid, ProcessLoopbackCapture.Mode mode)> ResolveCaptures(AppAudioScope scope)
|
||||
{
|
||||
return scope switch
|
||||
{
|
||||
OnlyApps o => o.Pids.Select(p => (p, ProcessLoopbackCapture.Mode.Include)).ToList(),
|
||||
AllExceptApps a when a.Pids.Count > 0 =>
|
||||
[(a.Pids[0], ProcessLoopbackCapture.Mode.Exclude)],
|
||||
// Entire desktop minus VoiceCat itself: EXCLUDE our own process tree.
|
||||
EntireDesktop { ExcludeSelf: true } =>
|
||||
[(Environment.ProcessId, ProcessLoopbackCapture.Mode.Exclude)],
|
||||
_ => [],
|
||||
};
|
||||
}
|
||||
|
||||
private static short[] MonoToStereo(short[] mono)
|
||||
{
|
||||
var stereo = new short[mono.Length * 2];
|
||||
for (int i = 0; i < mono.Length; i++)
|
||||
{
|
||||
stereo[i * 2] = mono[i];
|
||||
stereo[i * 2 + 1] = mono[i];
|
||||
}
|
||||
return stereo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// Process loopback requires MTA activation; blocking activation from the WinForms STA
|
||||
// deadlocks COM completion. The returned interfaces also reject RCW QueryInterface, so audio
|
||||
// calls use explicitly owned raw pointers and vtable dispatch.
|
||||
namespace VoiceCat.App.Audio;
|
||||
|
||||
public sealed class ProcessLoopbackCapture : IDisposable
|
||||
{
|
||||
public enum Mode { Include, Exclude }
|
||||
|
||||
// Fired on the capture thread every 20 ms (960 samples/channel @ 48 kHz, interleaved s16).
|
||||
public event Action<short[], int /*samplesPerChannel*/, int /*channels*/>? PcmFrameReady;
|
||||
|
||||
private const int SampleRate = 48000;
|
||||
private const int FrameSamples = 960; // 20 ms
|
||||
private const string LoopbackDevicePath = "VAD\\Process_Loopback";
|
||||
|
||||
private readonly int _pid;
|
||||
private readonly Mode _mode;
|
||||
|
||||
// Raw COM pointers — managed via explicit AddRef/Release, no RCW wrapping.
|
||||
private IntPtr _audioClientPtr; // IAudioClient*
|
||||
private IntPtr _captureClientPtr; // IAudioCaptureClient*
|
||||
|
||||
private AutoResetEvent? _bufferEvent;
|
||||
private Thread? _captureThread;
|
||||
private volatile bool _running;
|
||||
private int _channels;
|
||||
|
||||
// Accumulator: assembles driver-callback-sized fragments into FrameSamples chunks.
|
||||
private short[] _accumBuf = [];
|
||||
private int _accumCount;
|
||||
|
||||
// Init-done signal: Set() by the capture thread after ActivateClient() completes.
|
||||
private readonly ManualResetEventSlim _initDone = new(false);
|
||||
private bool _initOk;
|
||||
|
||||
public ProcessLoopbackCapture(int pid, Mode mode)
|
||||
{
|
||||
_pid = pid;
|
||||
_mode = mode;
|
||||
}
|
||||
|
||||
/// <summary>Starts capture. Blocks until WASAPI activation completes (typically <100 ms).
|
||||
/// Returns false if the process cannot be captured.</summary>
|
||||
public bool Start()
|
||||
{
|
||||
if (_running) return false;
|
||||
_running = true;
|
||||
_captureThread = new Thread(CaptureThreadProc)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = $"ProcLoopback:{_pid}",
|
||||
};
|
||||
_captureThread.Start();
|
||||
|
||||
bool ok = _initDone.Wait(5000) && _initOk;
|
||||
if (!ok) _running = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_running = false;
|
||||
_bufferEvent?.Set();
|
||||
_captureThread?.Join(500);
|
||||
if (_audioClientPtr != IntPtr.Zero) AC_Stop(_audioClientPtr);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
ComRelease(ref _captureClientPtr);
|
||||
ComRelease(ref _audioClientPtr);
|
||||
_bufferEvent?.Dispose();
|
||||
_initDone.Dispose();
|
||||
}
|
||||
|
||||
// ── Capture thread (MTA) ──────────────────────────────────────────────────
|
||||
|
||||
private void CaptureThreadProc()
|
||||
{
|
||||
_initOk = ActivateAndStart();
|
||||
_initDone.Set();
|
||||
if (!_initOk) return;
|
||||
CaptureLoop();
|
||||
}
|
||||
|
||||
private bool ActivateAndStart()
|
||||
{
|
||||
if (!ActivateClient()) return false;
|
||||
|
||||
_bufferEvent = new AutoResetEvent(false);
|
||||
if (AC_SetEventHandle(_audioClientPtr, _bufferEvent.SafeWaitHandle.DangerousGetHandle()) < 0)
|
||||
return false;
|
||||
|
||||
return AC_Start(_audioClientPtr) >= 0;
|
||||
}
|
||||
|
||||
// ── Activation ───────────────────────────────────────────────────────────
|
||||
|
||||
private unsafe bool ActivateClient()
|
||||
{
|
||||
var activationParams = new AudioClientActivationParams
|
||||
{
|
||||
ActivationType = 1, // AUDCLNT_ACTIVATIONTYPE_PROCESS_LOOPBACK
|
||||
TargetProcessId = (uint)_pid,
|
||||
ProcessLoopbackMode = _mode == Mode.Include ? 0u : 1u,
|
||||
};
|
||||
|
||||
var handler = new ActivationCompletionHandler();
|
||||
var audioClientIid = new Guid("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2");
|
||||
|
||||
IntPtr opPtr;
|
||||
{
|
||||
AudioClientActivationParams* pParams = &activationParams;
|
||||
// PROPVARIANT (VT_BLOB) x64: vt(2)+res(6)+cbSize(4)+pad(4)+pBlobData(8) = 24 B.
|
||||
var pv = stackalloc byte[24];
|
||||
*(ushort*)(pv + 0) = 65;
|
||||
*(uint*) (pv + 8) = (uint)sizeof(AudioClientActivationParams);
|
||||
*(nint*) (pv + 16) = (nint)pParams;
|
||||
|
||||
int hr = ActivateAudioInterfaceAsync(
|
||||
LoopbackDevicePath, ref audioClientIid,
|
||||
(IntPtr)pv, handler, out opPtr);
|
||||
if (hr < 0) return false;
|
||||
}
|
||||
|
||||
if (!handler.CompletionEvent.Wait(3000))
|
||||
{
|
||||
ComRelease(ref opPtr);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Call GetActivateResult via vtable (slot 3) — avoids QI on the async-op object.
|
||||
if (!Vtable_GetActivateResult(handler.OperationPtr, out int activateHr, out IntPtr activatedPtr))
|
||||
{
|
||||
handler.ReleaseOp();
|
||||
ComRelease(ref opPtr);
|
||||
return false;
|
||||
}
|
||||
handler.ReleaseOp();
|
||||
ComRelease(ref opPtr);
|
||||
|
||||
if (activateHr < 0 || activatedPtr == IntPtr.Zero) return false;
|
||||
|
||||
// Store the raw IAudioClient* — do NOT create an RCW; use vtable dispatch instead.
|
||||
_audioClientPtr = activatedPtr;
|
||||
// activatedPtr already has ref count from GetActivateResult; don't double-release.
|
||||
|
||||
return InitializeStream();
|
||||
}
|
||||
|
||||
// IActivateAudioInterfaceAsyncOperation vtable slot 3: GetActivateResult(HRESULT*, IUnknown**)
|
||||
private static unsafe bool Vtable_GetActivateResult(IntPtr op,
|
||||
out int activateHr, out IntPtr activatedPtr)
|
||||
{
|
||||
activateHr = unchecked((int)0x80004005);
|
||||
activatedPtr = IntPtr.Zero;
|
||||
if (op == IntPtr.Zero) return false;
|
||||
void** vtable = *(void***)op.ToPointer();
|
||||
var fn = (delegate* unmanaged[Stdcall]<IntPtr, int*, IntPtr*, int>)vtable[3];
|
||||
fixed (int* pHr = &activateHr)
|
||||
fixed (IntPtr* pPtr = &activatedPtr)
|
||||
return fn(op, pHr, pPtr) >= 0;
|
||||
}
|
||||
|
||||
private unsafe bool InitializeStream()
|
||||
{
|
||||
// Try s16 stereo first; fall back to s16 mono.
|
||||
foreach (int ch in new[] { 2, 1 })
|
||||
{
|
||||
var fmt = new WaveFormatEx
|
||||
{
|
||||
wFormatTag = 1, // WAVE_FORMAT_PCM
|
||||
nChannels = (ushort)ch,
|
||||
nSamplesPerSec = SampleRate,
|
||||
wBitsPerSample = 16,
|
||||
nBlockAlign = (ushort)(ch * 2),
|
||||
nAvgBytesPerSec = (uint)(SampleRate * ch * 2),
|
||||
cbSize = 0,
|
||||
};
|
||||
|
||||
// Process-loopback requires LOOPBACK (deliver rendered audio) + EVENTCALLBACK +
|
||||
// AUTOCONVERTPCM (resample the app's native format to our requested s16/48k). Without
|
||||
// LOOPBACK every buffer comes back AUDCLNT_BUFFERFLAGS_SILENT; without AUTOCONVERTPCM
|
||||
// the requested format is rejected. Matches the MS ApplicationLoopback sample.
|
||||
// AUDCLNT_SHAREMODE_SHARED = 0
|
||||
// AUDCLNT_STREAMFLAGS_LOOPBACK = 0x00020000
|
||||
// AUDCLNT_STREAMFLAGS_EVENTCALLBACK = 0x00040000
|
||||
// AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM = 0x80000000
|
||||
const uint streamFlags = 0x00020000u | 0x00040000u | 0x80000000u;
|
||||
int hr = AC_Initialize(_audioClientPtr, 0, streamFlags,
|
||||
2_000_000 /*200ms hns*/, 0, &fmt, null);
|
||||
if (hr >= 0)
|
||||
{
|
||||
_channels = ch;
|
||||
_accumBuf = new short[FrameSamples * ch];
|
||||
_accumCount = 0;
|
||||
break;
|
||||
}
|
||||
if (ch == 1) return false;
|
||||
}
|
||||
|
||||
var captureIid = new Guid("C8ADBD64-E71E-48a0-A4DE-185C395CD317");
|
||||
int getHr = AC_GetService(_audioClientPtr, ref captureIid, out _captureClientPtr);
|
||||
return getHr >= 0 && _captureClientPtr != IntPtr.Zero;
|
||||
}
|
||||
|
||||
// ── Capture loop ─────────────────────────────────────────────────────────
|
||||
|
||||
private void CaptureLoop()
|
||||
{
|
||||
while (_running)
|
||||
{
|
||||
_bufferEvent!.WaitOne(100);
|
||||
if (!_running) break;
|
||||
|
||||
while (_running)
|
||||
{
|
||||
int hr = CC_GetNextPacketSize(_captureClientPtr, out uint packetSize);
|
||||
if (hr < 0 || packetSize == 0) break;
|
||||
|
||||
hr = CC_GetBuffer(_captureClientPtr, out IntPtr dataPtr, out uint framesAvailable,
|
||||
out uint flags);
|
||||
if (hr < 0) break;
|
||||
|
||||
bool silent = (flags & 2) != 0; // AUDCLNT_BUFFERFLAGS_SILENT
|
||||
if (framesAvailable > 0)
|
||||
{
|
||||
if (silent) AccumulateSilence((int)framesAvailable);
|
||||
else AccumulatePcm(dataPtr, (int)framesAvailable);
|
||||
}
|
||||
|
||||
CC_ReleaseBuffer(_captureClientPtr, framesAvailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void AccumulatePcm(IntPtr data, int frames)
|
||||
{
|
||||
var src = (short*)data.ToPointer();
|
||||
int total = frames * _channels;
|
||||
int idx = 0;
|
||||
while (idx < total)
|
||||
{
|
||||
int space = _accumBuf.Length - _accumCount;
|
||||
int copy = Math.Min(total - idx, space);
|
||||
fixed (short* dst = _accumBuf)
|
||||
Buffer.MemoryCopy(src + idx, dst + _accumCount, copy * 2L, copy * 2L);
|
||||
_accumCount += copy;
|
||||
idx += copy;
|
||||
if (_accumCount == _accumBuf.Length)
|
||||
FlushFrame();
|
||||
}
|
||||
}
|
||||
|
||||
private void AccumulateSilence(int frames)
|
||||
{
|
||||
int total = frames * _channels;
|
||||
int idx = 0;
|
||||
while (idx < total)
|
||||
{
|
||||
int space = _accumBuf.Length - _accumCount;
|
||||
int fill = Math.Min(total - idx, space);
|
||||
Array.Clear(_accumBuf, _accumCount, fill);
|
||||
_accumCount += fill;
|
||||
idx += fill;
|
||||
if (_accumCount == _accumBuf.Length)
|
||||
FlushFrame();
|
||||
}
|
||||
}
|
||||
|
||||
private void FlushFrame()
|
||||
{
|
||||
var copy = new short[_accumBuf.Length];
|
||||
_accumBuf.AsSpan().CopyTo(copy);
|
||||
PcmFrameReady?.Invoke(copy, FrameSamples, _channels);
|
||||
_accumCount = 0;
|
||||
}
|
||||
|
||||
// ── IAudioClient vtable helpers (raw dispatch, no RCW / no QI) ───────────
|
||||
//
|
||||
// Vtable layout (IUnknown base: 0=QI 1=AddRef 2=Release; then IAudioClient methods):
|
||||
// 3=Initialize 4=GetBufferSize 5=GetStreamLatency 6=GetCurrentPadding
|
||||
// 7=IsFormatSupported 8=GetMixFormat 9=GetDevicePeriod
|
||||
// 10=Start 11=Stop 12=Reset 13=SetEventHandle 14=GetService
|
||||
|
||||
private static unsafe int AC_Initialize(IntPtr ac, int shareMode, uint streamFlags,
|
||||
long hnsBufferDuration, long hnsPeriodicity, WaveFormatEx* pFormat, Guid* pSession)
|
||||
{
|
||||
var fn = (delegate* unmanaged[Stdcall]<IntPtr, int, uint, long, long, WaveFormatEx*, Guid*, int>)
|
||||
(*(void***)ac)[3];
|
||||
return fn(ac, shareMode, streamFlags, hnsBufferDuration, hnsPeriodicity, pFormat, pSession);
|
||||
}
|
||||
|
||||
private static unsafe int AC_Start(IntPtr ac)
|
||||
{
|
||||
var fn = (delegate* unmanaged[Stdcall]<IntPtr, int>)(*(void***)ac)[10];
|
||||
return fn(ac);
|
||||
}
|
||||
|
||||
private static unsafe int AC_Stop(IntPtr ac)
|
||||
{
|
||||
var fn = (delegate* unmanaged[Stdcall]<IntPtr, int>)(*(void***)ac)[11];
|
||||
return fn(ac);
|
||||
}
|
||||
|
||||
private static unsafe int AC_SetEventHandle(IntPtr ac, IntPtr eventHandle)
|
||||
{
|
||||
var fn = (delegate* unmanaged[Stdcall]<IntPtr, IntPtr, int>)(*(void***)ac)[13];
|
||||
return fn(ac, eventHandle);
|
||||
}
|
||||
|
||||
private static unsafe int AC_GetService(IntPtr ac, ref Guid riid, out IntPtr ppv)
|
||||
{
|
||||
var fn = (delegate* unmanaged[Stdcall]<IntPtr, Guid*, IntPtr*, int>)(*(void***)ac)[14];
|
||||
fixed (Guid* pIid = &riid)
|
||||
fixed (IntPtr* pPpv = &ppv)
|
||||
return fn(ac, pIid, pPpv);
|
||||
}
|
||||
|
||||
// ── IAudioCaptureClient vtable helpers ────────────────────────────────────
|
||||
//
|
||||
// Vtable (IUnknown: 0-2; then): 3=GetBuffer 4=ReleaseBuffer 5=GetNextPacketSize
|
||||
|
||||
private static unsafe int CC_GetBuffer(IntPtr cc, out IntPtr ppData,
|
||||
out uint pNumFrames, out uint pdwFlags)
|
||||
{
|
||||
var fn = (delegate* unmanaged[Stdcall]<IntPtr, IntPtr*, uint*, uint*, ulong*, ulong*, int>)
|
||||
(*(void***)cc)[3];
|
||||
ulong devPos = 0, qpcPos = 0;
|
||||
fixed (IntPtr* p0 = &ppData)
|
||||
fixed (uint* p1 = &pNumFrames)
|
||||
fixed (uint* p2 = &pdwFlags)
|
||||
return fn(cc, p0, p1, p2, &devPos, &qpcPos);
|
||||
}
|
||||
|
||||
private static unsafe int CC_ReleaseBuffer(IntPtr cc, uint numFrames)
|
||||
{
|
||||
var fn = (delegate* unmanaged[Stdcall]<IntPtr, uint, int>)(*(void***)cc)[4];
|
||||
return fn(cc, numFrames);
|
||||
}
|
||||
|
||||
private static unsafe int CC_GetNextPacketSize(IntPtr cc, out uint pNumFrames)
|
||||
{
|
||||
var fn = (delegate* unmanaged[Stdcall]<IntPtr, uint*, int>)(*(void***)cc)[5];
|
||||
fixed (uint* p = &pNumFrames)
|
||||
return fn(cc, p);
|
||||
}
|
||||
|
||||
// ── COM utilities ─────────────────────────────────────────────────────────
|
||||
|
||||
private static unsafe void ComRelease(ref IntPtr ptr)
|
||||
{
|
||||
if (ptr == IntPtr.Zero) return;
|
||||
var fn = (delegate* unmanaged[Stdcall]<IntPtr, uint>)(*(void***)ptr)[2]; // IUnknown::Release
|
||||
fn(ptr);
|
||||
ptr = IntPtr.Zero;
|
||||
}
|
||||
|
||||
// ── P/Invoke & structs ────────────────────────────────────────────────────
|
||||
|
||||
[DllImport("Mmdevapi.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int ActivateAudioInterfaceAsync(
|
||||
string deviceInterfacePath,
|
||||
ref Guid riid,
|
||||
IntPtr activationParams,
|
||||
[MarshalAs(UnmanagedType.Interface)] IActivateAudioInterfaceCompletionHandler completionHandler,
|
||||
out IntPtr activationOperation);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct AudioClientActivationParams
|
||||
{
|
||||
public int ActivationType; // AUDCLNT_ACTIVATIONTYPE_PROCESS_LOOPBACK = 1
|
||||
public uint TargetProcessId;
|
||||
public uint ProcessLoopbackMode; // INCLUDE=0, EXCLUDE=1
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 2)]
|
||||
private struct WaveFormatEx
|
||||
{
|
||||
public ushort wFormatTag;
|
||||
public ushort nChannels;
|
||||
public uint nSamplesPerSec;
|
||||
public uint nAvgBytesPerSec;
|
||||
public ushort nBlockAlign;
|
||||
public ushort wBitsPerSample;
|
||||
public ushort cbSize;
|
||||
}
|
||||
|
||||
// ── Completion handler CCW ────────────────────────────────────────────────
|
||||
//
|
||||
// Only this object still uses .NET COM interop (as a CCW). The activateOperation
|
||||
// parameter is IntPtr to avoid QI on the incoming async-op pointer.
|
||||
|
||||
[ComVisible(true), ClassInterface(ClassInterfaceType.None)]
|
||||
private sealed class ActivationCompletionHandler : IActivateAudioInterfaceCompletionHandler
|
||||
{
|
||||
public readonly ManualResetEventSlim CompletionEvent = new(false);
|
||||
public IntPtr OperationPtr { get; private set; }
|
||||
|
||||
public void ActivateCompleted(IntPtr activateOperation)
|
||||
{
|
||||
OperationPtr = activateOperation;
|
||||
if (OperationPtr != IntPtr.Zero) Marshal.AddRef(OperationPtr);
|
||||
CompletionEvent.Set();
|
||||
}
|
||||
|
||||
public void ReleaseOp()
|
||||
{
|
||||
if (OperationPtr == IntPtr.Zero) return;
|
||||
Marshal.Release(OperationPtr);
|
||||
OperationPtr = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
[ComImport, Guid("41D949AB-9862-444A-80F6-C261334DA5EB"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IActivateAudioInterfaceCompletionHandler
|
||||
{
|
||||
void ActivateCompleted(IntPtr activateOperation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using VoiceCat.App.Audio;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Modal dialog for selecting which apps' audio to share.
|
||||
/// Returns the chosen <see cref="AppAudioScope"/> via <see cref="ChosenScope"/>,
|
||||
/// or <see cref="DialogResult.Cancel"/> if the user dismissed without sharing.
|
||||
/// </summary>
|
||||
public sealed class AppAudioPickerDialog : Form
|
||||
{
|
||||
private readonly RadioButton _rdoAll;
|
||||
private readonly RadioButton _rdoOnly;
|
||||
private readonly RadioButton _rdoExcept;
|
||||
private readonly CheckBox _chkExcludeSelf;
|
||||
private readonly TextBox _txtFilter;
|
||||
private readonly ListView _appList;
|
||||
private readonly Label _lblApps;
|
||||
|
||||
// Snapshot taken when the dialog opens (refresh on open, not on every check change).
|
||||
private IReadOnlyList<AudioAppInfo> _apps = [];
|
||||
|
||||
// Checked apps survive filtering (a filtered-out row keeps its check here).
|
||||
// pid → clean display name (used for the shared scope's Names).
|
||||
private readonly Dictionary<int, string> _checked = new();
|
||||
|
||||
public AppAudioScope? ChosenScope { get; private set; }
|
||||
|
||||
public AppAudioPickerDialog()
|
||||
{
|
||||
// ── Radio buttons ───────────────────────────────────────────────────
|
||||
_rdoAll = new RadioButton
|
||||
{
|
||||
Text = "&Entire desktop",
|
||||
Checked = true,
|
||||
Location = new Point(12, 12),
|
||||
Size = new Size(360, 20),
|
||||
TabIndex = 0,
|
||||
};
|
||||
_rdoOnly = new RadioButton
|
||||
{
|
||||
Text = "&Only selected apps",
|
||||
Location = new Point(12, 36),
|
||||
Size = new Size(360, 20),
|
||||
TabIndex = 1,
|
||||
};
|
||||
_rdoExcept = new RadioButton
|
||||
{
|
||||
Text = "All apps e&xcept selected",
|
||||
Location = new Point(12, 60),
|
||||
Size = new Size(360, 20),
|
||||
TabIndex = 2,
|
||||
};
|
||||
|
||||
_rdoAll.CheckedChanged += OnModeChanged;
|
||||
_rdoOnly.CheckedChanged += OnModeChanged;
|
||||
_rdoExcept.CheckedChanged += OnModeChanged;
|
||||
|
||||
// ── Self-exclude (echo prevention) ───────────────────────────────────
|
||||
// Captures the whole desktop minus VoiceCat's own playback, so the channel
|
||||
// doesn't echo back the voices you're already hearing. Only applies to the
|
||||
// entire-desktop mode (the per-app modes already exclude this app's tree).
|
||||
_chkExcludeSelf = new CheckBox
|
||||
{
|
||||
Text = "E&xclude VoiceCat's own audio (prevents echo)",
|
||||
Checked = true,
|
||||
Location = new Point(28, 84),
|
||||
Size = new Size(344, 20),
|
||||
TabIndex = 3,
|
||||
};
|
||||
|
||||
// ── App list ────────────────────────────────────────────────────────
|
||||
_lblApps = new Label
|
||||
{
|
||||
Text = "Apps (▶ = currently playing):",
|
||||
AutoSize = true,
|
||||
Location = new Point(12, 112),
|
||||
Visible = false,
|
||||
TabIndex = 4,
|
||||
};
|
||||
|
||||
_txtFilter = new TextBox
|
||||
{
|
||||
Location = new Point(12, 132),
|
||||
Size = new Size(360, 23),
|
||||
PlaceholderText = "Filter apps…",
|
||||
Visible = false,
|
||||
TabIndex = 5,
|
||||
};
|
||||
_txtFilter.TextChanged += (_, _) => PopulateList();
|
||||
|
||||
_appList = new ListView
|
||||
{
|
||||
Location = new Point(12, 160),
|
||||
Size = new Size(360, 150),
|
||||
CheckBoxes = true,
|
||||
View = View.List,
|
||||
Visible = false,
|
||||
TabIndex = 6,
|
||||
FullRowSelect = true,
|
||||
};
|
||||
// Exclude mode supports only one target process tree (WASAPI EXCLUDE takes a single
|
||||
// PID), so enforce a single check while "All apps except selected" is active.
|
||||
_appList.ItemCheck += OnAppItemCheck;
|
||||
|
||||
// ── Buttons ─────────────────────────────────────────────────────────
|
||||
var btnShare = new Button
|
||||
{
|
||||
Text = "&Share",
|
||||
DialogResult = DialogResult.OK,
|
||||
Location = new Point(216, 320),
|
||||
Size = new Size(75, 27),
|
||||
TabIndex = 7,
|
||||
};
|
||||
var btnCancel = new Button
|
||||
{
|
||||
Text = "&Cancel",
|
||||
DialogResult = DialogResult.Cancel,
|
||||
Location = new Point(297, 320),
|
||||
Size = new Size(75, 27),
|
||||
TabIndex = 8,
|
||||
};
|
||||
btnShare.Click += OnShareClick;
|
||||
|
||||
AcceptButton = btnShare;
|
||||
CancelButton = btnCancel;
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(384, 360);
|
||||
Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _chkExcludeSelf, _lblApps, _txtFilter,
|
||||
_appList, btnShare, btnCancel]);
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
Text = "Share App Audio";
|
||||
}
|
||||
|
||||
protected override void OnLoad(EventArgs e)
|
||||
{
|
||||
base.OnLoad(e);
|
||||
RefreshAppList();
|
||||
}
|
||||
|
||||
private void RefreshAppList()
|
||||
{
|
||||
_apps = AudioSessionEnumerator.GetAudioApps();
|
||||
PopulateList();
|
||||
}
|
||||
|
||||
// Rebuild the visible rows from _apps + the current filter text, restoring check
|
||||
// state from _checked so a selection persists while the user filters.
|
||||
private void PopulateList()
|
||||
{
|
||||
string filter = _txtFilter.Text.Trim();
|
||||
|
||||
_suppressItemCheck = true;
|
||||
_appList.BeginUpdate();
|
||||
_appList.Items.Clear();
|
||||
foreach (var app in _apps)
|
||||
{
|
||||
if (filter.Length > 0 &&
|
||||
app.DisplayName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
continue;
|
||||
|
||||
string marker = app.IsPlaying ? "▶ " : "";
|
||||
_appList.Items.Add(new ListViewItem($"{marker}{app.DisplayName} (PID {app.Pid})")
|
||||
{
|
||||
Tag = app,
|
||||
Checked = _checked.ContainsKey(app.Pid),
|
||||
});
|
||||
}
|
||||
_appList.EndUpdate();
|
||||
_suppressItemCheck = false;
|
||||
}
|
||||
|
||||
private void OnModeChanged(object? sender, EventArgs e)
|
||||
{
|
||||
bool showList = _rdoOnly.Checked || _rdoExcept.Checked;
|
||||
_lblApps.Visible = showList;
|
||||
_txtFilter.Visible = showList;
|
||||
_appList.Visible = showList;
|
||||
_lblApps.Text = _rdoExcept.Checked
|
||||
? "App to exclude (everything else is shared):"
|
||||
: "Apps (▶ = currently playing):";
|
||||
|
||||
// Self-exclude only applies to entire-desktop; the per-app modes already exclude
|
||||
// this app's own tree (Include) or spend the single EXCLUDE slot on the chosen app.
|
||||
_chkExcludeSelf.Enabled = _rdoAll.Checked;
|
||||
|
||||
// Switching into exclude mode: collapse any multi-selection down to a single item.
|
||||
if (_rdoExcept.Checked)
|
||||
TrimCheckedToOne();
|
||||
|
||||
if (showList && _appList.Items.Count == 0)
|
||||
RefreshAppList();
|
||||
else
|
||||
PopulateList(); // re-render markers/labels and restore check state
|
||||
}
|
||||
|
||||
// In exclude mode the list behaves like radio buttons: checking one item clears the rest.
|
||||
private bool _suppressItemCheck;
|
||||
|
||||
private void OnAppItemCheck(object? sender, ItemCheckEventArgs e)
|
||||
{
|
||||
if (_suppressItemCheck) return;
|
||||
if (_appList.Items[e.Index].Tag is not AudioAppInfo app) return;
|
||||
|
||||
if (e.NewValue == CheckState.Checked)
|
||||
{
|
||||
if (_rdoExcept.Checked)
|
||||
{
|
||||
// Radio behavior: clear every other visible check and the persisted set.
|
||||
_suppressItemCheck = true;
|
||||
foreach (ListViewItem item in _appList.Items)
|
||||
if (item.Index != e.Index && item.Checked) item.Checked = false;
|
||||
_suppressItemCheck = false;
|
||||
_checked.Clear();
|
||||
}
|
||||
_checked[app.Pid] = app.DisplayName;
|
||||
}
|
||||
else
|
||||
{
|
||||
_checked.Remove(app.Pid);
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce the persisted selection to a single (first) entry — used when entering
|
||||
// exclude mode, whose single EXCLUDE slot can only target one process tree.
|
||||
private void TrimCheckedToOne()
|
||||
{
|
||||
if (_checked.Count <= 1) return;
|
||||
var first = _checked.First();
|
||||
_checked.Clear();
|
||||
_checked[first.Key] = first.Value;
|
||||
}
|
||||
|
||||
private void OnShareClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_rdoAll.Checked)
|
||||
{
|
||||
ChosenScope = new EntireDesktop(_chkExcludeSelf.Checked);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_checked.Count == 0)
|
||||
{
|
||||
MessageBox.Show(
|
||||
"Select at least one app, or choose 'Entire desktop'.",
|
||||
"No apps selected",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
DialogResult = DialogResult.None; // prevent close
|
||||
return;
|
||||
}
|
||||
|
||||
var pids = _checked.Keys.ToList();
|
||||
var names = _checked.Values.ToList();
|
||||
|
||||
ChosenScope = _rdoOnly.Checked
|
||||
? new OnlyApps(pids, names)
|
||||
: new AllExceptApps(pids, names);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
using System.ComponentModel;
|
||||
using VoiceCat.App.Audio;
|
||||
using VoiceCat.App.Models;
|
||||
using VoiceCat.Interop;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Edits audio input settings: device selection, transmission mode (VAD/PTT/always-on),
|
||||
/// VAD sensitivity, mic gain, and PTT key. Changes are applied live to the client for
|
||||
/// immediate feedback; Cancel reverts them.
|
||||
/// </summary>
|
||||
public sealed class AudioSettingsForm : Form
|
||||
{
|
||||
private readonly VoiceCatClient _client;
|
||||
private readonly VoiceSettings _settings;
|
||||
private readonly uint _micStreamId;
|
||||
|
||||
// Aux-stream live-apply callbacks, supplied by MainForm (which owns the aux capture). Null
|
||||
// when not connected — the controls still edit settings, just without live effect.
|
||||
private readonly Action<bool>? _applyAuxEnabled;
|
||||
private readonly Action<string?>? _applyAuxDevice;
|
||||
private readonly Action<float>? _applyAuxGain;
|
||||
|
||||
// Snapshot of original values so Cancel can restore them
|
||||
private readonly string? _origDeviceId;
|
||||
private readonly VcInputMode _origMode;
|
||||
private readonly int _origVadSlider;
|
||||
private readonly int _origMicGain;
|
||||
private readonly bool _origMicNoiseReduction;
|
||||
private readonly bool _origStereoMic;
|
||||
private readonly Keys _origPttKey;
|
||||
private readonly bool _origSystemWidePtt;
|
||||
private readonly bool _origAuxEnabled;
|
||||
private readonly string? _origAuxDeviceId;
|
||||
private readonly int _origAuxGain;
|
||||
|
||||
private readonly ComboBox _cboDevice;
|
||||
private readonly Button _btnRefresh;
|
||||
private readonly RadioButton _radioVad;
|
||||
private readonly RadioButton _radioPtt;
|
||||
private readonly RadioButton _radioAlwaysOn;
|
||||
private readonly Label _lblPttKey;
|
||||
private readonly Button _btnChangePtt;
|
||||
private readonly CheckBox _chkSystemWidePtt;
|
||||
private readonly Label _lblSensitivity;
|
||||
private readonly TrackBar _trkVad;
|
||||
private readonly TrackBar _trkGain;
|
||||
private readonly CheckBox _chkNoiseReduction;
|
||||
private readonly CheckBox _chkStereoMic;
|
||||
private readonly CheckBox _chkAux;
|
||||
private readonly Label _lblAuxDevice;
|
||||
private readonly ComboBox _cboAuxDevice;
|
||||
private readonly Button _btnAuxRefresh;
|
||||
private readonly Label _lblAuxGain;
|
||||
private readonly TrackBar _trkAuxGain;
|
||||
|
||||
private Keys _pttKey;
|
||||
|
||||
public AudioSettingsForm(VoiceCatClient client, VoiceSettings settings, uint micStreamId,
|
||||
Action<bool>? applyAuxEnabled = null, Action<string?>? applyAuxDevice = null,
|
||||
Action<float>? applyAuxGain = null)
|
||||
{
|
||||
_client = client;
|
||||
_settings = settings;
|
||||
_micStreamId = micStreamId;
|
||||
_applyAuxEnabled = applyAuxEnabled;
|
||||
_applyAuxDevice = applyAuxDevice;
|
||||
_applyAuxGain = applyAuxGain;
|
||||
_pttKey = (Keys)settings.PttKey;
|
||||
|
||||
_origDeviceId = settings.InputDeviceId;
|
||||
_origMode = (VcInputMode)settings.InputMode;
|
||||
_origVadSlider = settings.VadThresholdSlider;
|
||||
_origMicGain = settings.MicGain;
|
||||
_origMicNoiseReduction = settings.MicNoiseReduction;
|
||||
_origStereoMic = settings.StereoMic;
|
||||
_origPttKey = _pttKey;
|
||||
_origSystemWidePtt = settings.SystemWidePtt;
|
||||
_origAuxEnabled = settings.AuxEnabled;
|
||||
_origAuxDeviceId = settings.AuxDeviceId;
|
||||
_origAuxGain = settings.AuxGain;
|
||||
|
||||
Text = "Audio settings";
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(420, 639);
|
||||
|
||||
// ── Device row ────────────────────────────────────────────────────────
|
||||
var lblDevice = new Label
|
||||
{
|
||||
Text = "Input &device:",
|
||||
Location = new Point(12, 16),
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
_cboDevice = new ComboBox
|
||||
{
|
||||
Location = new Point(12, 36),
|
||||
Width = 300,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
DisplayMember = "Name",
|
||||
ValueMember = "Id",
|
||||
AccessibleName = "Input device",
|
||||
AccessibleDescription = "Select which microphone or audio input device to use.",
|
||||
};
|
||||
_cboDevice.SelectedIndexChanged += CboDevice_SelectedIndexChanged;
|
||||
|
||||
_btnRefresh = new Button
|
||||
{
|
||||
Text = "&Refresh",
|
||||
Location = new Point(320, 34),
|
||||
Size = new Size(80, 26),
|
||||
};
|
||||
_btnRefresh.Click += (_, _) => LoadDevices();
|
||||
|
||||
// ── Transmission mode ─────────────────────────────────────────────────
|
||||
var lblMode = new Label
|
||||
{
|
||||
Text = "Transmission mode:",
|
||||
Location = new Point(12, 76),
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
_radioVad = new RadioButton
|
||||
{
|
||||
Text = "&Voice activation",
|
||||
Location = new Point(20, 96),
|
||||
AutoSize = true,
|
||||
};
|
||||
_radioVad.CheckedChanged += RadioMode_CheckedChanged;
|
||||
|
||||
_lblSensitivity = new Label
|
||||
{
|
||||
Text = "Sensitivity:",
|
||||
Location = new Point(36, 122),
|
||||
AutoSize = true,
|
||||
};
|
||||
_trkVad = new TrackBar
|
||||
{
|
||||
Location = new Point(36, 140),
|
||||
Size = new Size(200, 45),
|
||||
Minimum = 1,
|
||||
Maximum = 100,
|
||||
TickFrequency = 10,
|
||||
SmallChange = 1,
|
||||
LargeChange = 10,
|
||||
Value = Math.Clamp(settings.VadThresholdSlider, 1, 100),
|
||||
};
|
||||
_trkVad.AccessibleName = "VAD sensitivity";
|
||||
_trkVad.AccessibleDescription =
|
||||
"Voice detection sensitivity. Higher = more sensitive. Range 1–100.";
|
||||
_trkVad.Scroll += TrkVad_Scroll;
|
||||
|
||||
_radioPtt = new RadioButton
|
||||
{
|
||||
Text = "&Push to talk",
|
||||
Location = new Point(20, 192),
|
||||
AutoSize = true,
|
||||
};
|
||||
_radioPtt.CheckedChanged += RadioMode_CheckedChanged;
|
||||
|
||||
_lblPttKey = new Label
|
||||
{
|
||||
Location = new Point(140, 194),
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
_btnChangePtt = new Button
|
||||
{
|
||||
Text = "Change key...",
|
||||
Location = new Point(200, 189),
|
||||
Size = new Size(105, 26),
|
||||
};
|
||||
_btnChangePtt.Click += BtnChangePtt_Click;
|
||||
|
||||
// Indented PTT sub-option: observe the key system-wide (Raw Input) so it works while
|
||||
// another app is focused. Visible only in PTT mode (RadioMode_CheckedChanged).
|
||||
_chkSystemWidePtt = new CheckBox
|
||||
{
|
||||
Text = "Wor&ks in the background (system-wide)",
|
||||
Location = new Point(36, 218),
|
||||
AutoSize = true,
|
||||
Checked = settings.SystemWidePtt,
|
||||
AccessibleName = "System-wide push-to-talk",
|
||||
AccessibleDescription =
|
||||
"Let push-to-talk work while another application is focused. Uses the Windows Raw " +
|
||||
"Input API, not a keyboard hook.",
|
||||
};
|
||||
_chkSystemWidePtt.CheckedChanged += ChkSystemWidePtt_CheckedChanged;
|
||||
|
||||
_radioAlwaysOn = new RadioButton
|
||||
{
|
||||
Text = "A&lways on",
|
||||
Location = new Point(20, 252),
|
||||
AutoSize = true,
|
||||
};
|
||||
_radioAlwaysOn.CheckedChanged += RadioMode_CheckedChanged;
|
||||
|
||||
// ── Mic gain ──────────────────────────────────────────────────────────
|
||||
var lblGain = new Label
|
||||
{
|
||||
Text = "Microphone &volume:",
|
||||
Location = new Point(12, 296),
|
||||
AutoSize = true,
|
||||
};
|
||||
_trkGain = new TrackBar
|
||||
{
|
||||
Location = new Point(12, 316),
|
||||
Size = new Size(200, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 400,
|
||||
TickFrequency = 25,
|
||||
SmallChange = 5,
|
||||
LargeChange = 25,
|
||||
Value = Math.Clamp(settings.MicGain, 0, 400),
|
||||
};
|
||||
_trkGain.AccessibleName = "Microphone volume";
|
||||
_trkGain.AccessibleDescription =
|
||||
"Boost a quiet microphone. 100 is unity gain; range 0–400 percent.";
|
||||
_trkGain.Scroll += TrkGain_Scroll;
|
||||
|
||||
// ── Mic noise reduction ───────────────────────────────────────────────
|
||||
// Send-side RNNoise denoise of the mic stream. MIC-only (the core's NR runs before
|
||||
// the input gain and VAD/PTT gate; aux/screen are excluded). One pass for all listeners.
|
||||
_chkNoiseReduction = new CheckBox
|
||||
{
|
||||
Text = "Noise &reduction (RNNoise)",
|
||||
Location = new Point(12, 368),
|
||||
AutoSize = true,
|
||||
Checked = settings.MicNoiseReduction,
|
||||
AccessibleName = "Microphone noise reduction",
|
||||
AccessibleDescription =
|
||||
"RNNoise denoising of your microphone. Cleans your signal for everyone listening.",
|
||||
};
|
||||
_chkNoiseReduction.CheckedChanged += ChkNoiseReduction_CheckedChanged;
|
||||
|
||||
// ── Stereo microphone ─────────────────────────────────────────────────
|
||||
// Opens the mic capture device in stereo (interleaved L/R) instead of mono. Real stereo
|
||||
// only reaches the wire on a stereo channel; the core folds a stereo mic to mono on a mono
|
||||
// channel. Toggling while connected restarts the capture device (vc_audio_restart) so the
|
||||
// new channel count takes effect immediately.
|
||||
_chkStereoMic = new CheckBox
|
||||
{
|
||||
Text = "&Stereo microphone",
|
||||
Location = new Point(12, 392),
|
||||
AutoSize = true,
|
||||
Checked = settings.StereoMic,
|
||||
AccessibleName = "Stereo microphone",
|
||||
AccessibleDescription =
|
||||
"Capture your microphone in stereo. Only transmitted in stereo on a stereo channel.",
|
||||
};
|
||||
_chkStereoMic.CheckedChanged += ChkStereoMic_CheckedChanged;
|
||||
|
||||
// ── Aux input stream ──────────────────────────────────────────────────
|
||||
// A second outgoing stream from another hardware input device (e.g. line-in / aux),
|
||||
// captured client-side and fed to the core. Device + volume only — aux is always-on
|
||||
// (the core never gates AUX_DEVICE on VAD/PTT).
|
||||
_chkAux = new CheckBox
|
||||
{
|
||||
Text = "&Aux stream (second input device)",
|
||||
Location = new Point(12, 432),
|
||||
AutoSize = true,
|
||||
Checked = settings.AuxEnabled,
|
||||
AccessibleName = "Enable aux input stream",
|
||||
AccessibleDescription =
|
||||
"Transmit a second hardware input device alongside your microphone.",
|
||||
};
|
||||
_chkAux.CheckedChanged += ChkAux_CheckedChanged;
|
||||
|
||||
_lblAuxDevice = new Label
|
||||
{
|
||||
Text = "Aux d&evice:",
|
||||
Location = new Point(12, 462),
|
||||
AutoSize = true,
|
||||
};
|
||||
_cboAuxDevice = new ComboBox
|
||||
{
|
||||
Location = new Point(12, 482),
|
||||
Width = 300,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
DisplayMember = "Name",
|
||||
ValueMember = "Id",
|
||||
AccessibleName = "Aux input device",
|
||||
AccessibleDescription = "Select the second audio input device to transmit.",
|
||||
};
|
||||
_cboAuxDevice.SelectedIndexChanged += CboAuxDevice_SelectedIndexChanged;
|
||||
|
||||
_btnAuxRefresh = new Button
|
||||
{
|
||||
Text = "Re&fresh",
|
||||
Location = new Point(320, 480),
|
||||
Size = new Size(80, 26),
|
||||
};
|
||||
_btnAuxRefresh.Click += (_, _) => LoadAuxDevices();
|
||||
|
||||
_lblAuxGain = new Label
|
||||
{
|
||||
Text = "Aux vo&lume:",
|
||||
Location = new Point(12, 518),
|
||||
AutoSize = true,
|
||||
};
|
||||
_trkAuxGain = new TrackBar
|
||||
{
|
||||
Location = new Point(12, 538),
|
||||
Size = new Size(200, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 400,
|
||||
TickFrequency = 25,
|
||||
SmallChange = 5,
|
||||
LargeChange = 25,
|
||||
Value = Math.Clamp(settings.AuxGain, 0, 400),
|
||||
};
|
||||
_trkAuxGain.AccessibleName = "Aux volume";
|
||||
_trkAuxGain.AccessibleDescription =
|
||||
"Volume of the aux input stream. 100 is unity gain; range 0–400 percent.";
|
||||
_trkAuxGain.Scroll += TrkAuxGain_Scroll;
|
||||
|
||||
// ── OK / Cancel ───────────────────────────────────────────────────────
|
||||
var btnOk = new Button
|
||||
{
|
||||
Text = "&OK",
|
||||
DialogResult = DialogResult.OK,
|
||||
Location = new Point(228, 600),
|
||||
Size = new Size(80, 27),
|
||||
};
|
||||
var btnCancel = new Button
|
||||
{
|
||||
Text = "&Cancel",
|
||||
DialogResult = DialogResult.Cancel,
|
||||
Location = new Point(316, 600),
|
||||
Size = new Size(80, 27),
|
||||
};
|
||||
|
||||
AcceptButton = btnOk;
|
||||
CancelButton = btnCancel;
|
||||
|
||||
btnOk.Click += (_, _) =>
|
||||
{
|
||||
_settings.Save();
|
||||
};
|
||||
FormClosing += AudioSettingsForm_FormClosing;
|
||||
|
||||
Controls.AddRange([
|
||||
lblDevice, _cboDevice, _btnRefresh,
|
||||
lblMode, _radioVad, _lblSensitivity, _trkVad,
|
||||
_radioPtt, _lblPttKey, _btnChangePtt, _chkSystemWidePtt, _radioAlwaysOn,
|
||||
lblGain, _trkGain, _chkNoiseReduction, _chkStereoMic,
|
||||
_chkAux, _lblAuxDevice, _cboAuxDevice, _btnAuxRefresh, _lblAuxGain, _trkAuxGain,
|
||||
btnOk, btnCancel,
|
||||
]);
|
||||
|
||||
// Apply saved mode (fires RadioMode_CheckedChanged which sets visibility)
|
||||
switch ((VcInputMode)settings.InputMode)
|
||||
{
|
||||
case VcInputMode.PushToTalk: _radioPtt.Checked = true; break;
|
||||
case VcInputMode.AlwaysOn: _radioAlwaysOn.Checked = true; break;
|
||||
default: _radioVad.Checked = true; break;
|
||||
}
|
||||
|
||||
LoadDevices();
|
||||
LoadAuxDevices();
|
||||
UpdateAuxControlsEnabled();
|
||||
}
|
||||
|
||||
private void LoadDevices()
|
||||
{
|
||||
string? currentId = (_cboDevice.SelectedItem as DeviceInfo)?.Id ?? _settings.InputDeviceId;
|
||||
|
||||
var devices = _client.ListDevices(VcDeviceKind.Input);
|
||||
_cboDevice.SelectedIndexChanged -= CboDevice_SelectedIndexChanged;
|
||||
_cboDevice.DataSource = new BindingList<DeviceInfo>(devices);
|
||||
_cboDevice.SelectedIndexChanged += CboDevice_SelectedIndexChanged;
|
||||
|
||||
// Try to restore previous selection
|
||||
int idx = -1;
|
||||
if (currentId is not null)
|
||||
idx = devices.FindIndex(d => d.Id == currentId);
|
||||
if (idx < 0)
|
||||
idx = devices.FindIndex(d => d.IsDefault);
|
||||
_cboDevice.SelectedIndex = idx >= 0 ? idx : (devices.Count > 0 ? 0 : -1);
|
||||
}
|
||||
|
||||
private void CboDevice_SelectedIndexChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_cboDevice.SelectedItem is not DeviceInfo dev) return;
|
||||
_settings.InputDeviceId = dev.IsDefault ? null : dev.Id;
|
||||
if (_micStreamId != 0)
|
||||
_client.SetInputDevice(_micStreamId, dev.IsDefault ? null : dev.Id);
|
||||
}
|
||||
|
||||
// ── Aux input stream ──────────────────────────────────────────────────────
|
||||
|
||||
private void LoadAuxDevices()
|
||||
{
|
||||
string? currentId = (_cboAuxDevice.SelectedItem as InputDeviceInfo)?.Id
|
||||
?? _settings.AuxDeviceId;
|
||||
|
||||
var devices = InputDeviceEnumerator.List().ToList();
|
||||
|
||||
// Set the data source AND restore the selection while unsubscribed so neither the initial
|
||||
// populate nor a Refresh fires a spurious device-change (which would restart the capture).
|
||||
_cboAuxDevice.SelectedIndexChanged -= CboAuxDevice_SelectedIndexChanged;
|
||||
_cboAuxDevice.DataSource = new BindingList<InputDeviceInfo>(devices);
|
||||
|
||||
int idx = -1;
|
||||
if (currentId is not null)
|
||||
idx = devices.FindIndex(d => d.Id == currentId);
|
||||
if (idx < 0)
|
||||
idx = devices.FindIndex(d => d.IsDefault);
|
||||
_cboAuxDevice.SelectedIndex = idx >= 0 ? idx : (devices.Count > 0 ? 0 : -1);
|
||||
_cboAuxDevice.SelectedIndexChanged += CboAuxDevice_SelectedIndexChanged;
|
||||
|
||||
// Persist the resolved selection (null = default) so Join Voice opens the same device.
|
||||
if (_cboAuxDevice.SelectedItem is InputDeviceInfo dev)
|
||||
_settings.AuxDeviceId = dev.IsDefault ? null : dev.Id;
|
||||
}
|
||||
|
||||
private void ChkAux_CheckedChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_settings.AuxEnabled = _chkAux.Checked;
|
||||
UpdateAuxControlsEnabled();
|
||||
_applyAuxEnabled?.Invoke(_chkAux.Checked);
|
||||
}
|
||||
|
||||
private void CboAuxDevice_SelectedIndexChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_cboAuxDevice.SelectedItem is not InputDeviceInfo dev) return;
|
||||
_settings.AuxDeviceId = dev.IsDefault ? null : dev.Id;
|
||||
if (_chkAux.Checked)
|
||||
_applyAuxDevice?.Invoke(dev.IsDefault ? null : dev.Id);
|
||||
}
|
||||
|
||||
private void TrkAuxGain_Scroll(object? sender, EventArgs e)
|
||||
{
|
||||
_settings.AuxGain = _trkAuxGain.Value;
|
||||
if (_chkAux.Checked)
|
||||
_applyAuxGain?.Invoke(_trkAuxGain.Value / 100f);
|
||||
}
|
||||
|
||||
private void UpdateAuxControlsEnabled()
|
||||
{
|
||||
bool on = _chkAux.Checked;
|
||||
_lblAuxDevice.Enabled = on;
|
||||
_cboAuxDevice.Enabled = on;
|
||||
_btnAuxRefresh.Enabled = on;
|
||||
_lblAuxGain.Enabled = on;
|
||||
_trkAuxGain.Enabled = on;
|
||||
}
|
||||
|
||||
private void RadioMode_CheckedChanged(object? sender, EventArgs e)
|
||||
{
|
||||
var mode = CurrentMode();
|
||||
bool isVad = mode == VcInputMode.VoiceActivation;
|
||||
bool isPtt = mode == VcInputMode.PushToTalk;
|
||||
|
||||
_lblSensitivity.Visible = isVad;
|
||||
_trkVad.Visible = isVad;
|
||||
_lblPttKey.Visible = isPtt;
|
||||
_btnChangePtt.Visible = isPtt;
|
||||
_chkSystemWidePtt.Visible = isPtt;
|
||||
UpdatePttKeyLabel();
|
||||
|
||||
_settings.InputMode = (int)mode;
|
||||
if (_micStreamId != 0)
|
||||
{
|
||||
_client.SetInputMode(mode);
|
||||
if (isVad) _client.SetVadThreshold(VadThresholdFromSlider());
|
||||
if (isPtt) _client.SetPushToTalk(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void TrkVad_Scroll(object? sender, EventArgs e)
|
||||
{
|
||||
_settings.VadThresholdSlider = _trkVad.Value;
|
||||
if (_micStreamId != 0 && CurrentMode() == VcInputMode.VoiceActivation)
|
||||
_client.SetVadThreshold(VadThresholdFromSlider());
|
||||
}
|
||||
|
||||
private void TrkGain_Scroll(object? sender, EventArgs e)
|
||||
{
|
||||
_settings.MicGain = _trkGain.Value;
|
||||
if (_micStreamId != 0)
|
||||
_client.SetInputGain(_trkGain.Value / 100f);
|
||||
}
|
||||
|
||||
private void ChkNoiseReduction_CheckedChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_settings.MicNoiseReduction = _chkNoiseReduction.Checked;
|
||||
if (_micStreamId != 0)
|
||||
_client.SetInputNoiseReduction(_chkNoiseReduction.Checked);
|
||||
}
|
||||
|
||||
private void ChkStereoMic_CheckedChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_settings.StereoMic = _chkStereoMic.Checked;
|
||||
// Channel count only takes effect when the capture device (re)starts, so restart it live.
|
||||
if (_micStreamId != 0)
|
||||
{
|
||||
_client.SetCaptureChannels(_micStreamId, _chkStereoMic.Checked ? 2u : 1u);
|
||||
_client.AudioRestart();
|
||||
}
|
||||
}
|
||||
|
||||
private void ChkSystemWidePtt_CheckedChanged(object? sender, EventArgs e) =>
|
||||
// No live effect here — MainForm reads SystemWidePtt after the dialog closes and
|
||||
// registers/unregisters the Raw Input keyboard sink accordingly.
|
||||
_settings.SystemWidePtt = _chkSystemWidePtt.Checked;
|
||||
|
||||
private void BtnChangePtt_Click(object? sender, EventArgs e)
|
||||
{
|
||||
using var dlg = new PttKeyCaptureDialog(_pttKey);
|
||||
if (dlg.ShowDialog(this) == DialogResult.OK)
|
||||
{
|
||||
_pttKey = dlg.CapturedKey;
|
||||
_settings.PttKey = (int)_pttKey;
|
||||
UpdatePttKeyLabel();
|
||||
}
|
||||
}
|
||||
|
||||
private void AudioSettingsForm_FormClosing(object? sender, FormClosingEventArgs e)
|
||||
{
|
||||
if (DialogResult == DialogResult.OK) return;
|
||||
|
||||
// Cancel: restore originals to settings and live client
|
||||
_settings.InputDeviceId = _origDeviceId;
|
||||
_settings.InputMode = (int)_origMode;
|
||||
_settings.VadThresholdSlider = _origVadSlider;
|
||||
_settings.MicGain = _origMicGain;
|
||||
_settings.MicNoiseReduction = _origMicNoiseReduction;
|
||||
_settings.StereoMic = _origStereoMic;
|
||||
_settings.PttKey = (int)_origPttKey;
|
||||
_settings.SystemWidePtt = _origSystemWidePtt;
|
||||
|
||||
if (_micStreamId != 0)
|
||||
{
|
||||
_client.SetInputDevice(_micStreamId, _origDeviceId);
|
||||
_client.SetInputMode(_origMode);
|
||||
if (_origMode == VcInputMode.VoiceActivation)
|
||||
_client.SetVadThreshold(0.1f * (1f - (_origVadSlider - 1f) / 99f));
|
||||
_client.SetInputGain(_origMicGain / 100f);
|
||||
_client.SetInputNoiseReduction(_origMicNoiseReduction);
|
||||
// Restore capture channel count; restart the device only if it actually changed.
|
||||
if (_origStereoMic != _chkStereoMic.Checked)
|
||||
{
|
||||
_client.SetCaptureChannels(_micStreamId, _origStereoMic ? 2u : 1u);
|
||||
_client.AudioRestart();
|
||||
}
|
||||
}
|
||||
|
||||
// Aux: restore originals to settings and re-apply live (order: device + gain first so a
|
||||
// re-enable starts with the right configuration).
|
||||
_settings.AuxEnabled = _origAuxEnabled;
|
||||
_settings.AuxDeviceId = _origAuxDeviceId;
|
||||
_settings.AuxGain = _origAuxGain;
|
||||
_applyAuxDevice?.Invoke(_origAuxDeviceId);
|
||||
_applyAuxGain?.Invoke(_origAuxGain / 100f);
|
||||
_applyAuxEnabled?.Invoke(_origAuxEnabled);
|
||||
}
|
||||
|
||||
private void UpdatePttKeyLabel() =>
|
||||
_lblPttKey.Text = $"({_pttKey})";
|
||||
|
||||
private VcInputMode CurrentMode() =>
|
||||
_radioPtt.Checked ? VcInputMode.PushToTalk :
|
||||
_radioAlwaysOn.Checked ? VcInputMode.AlwaysOn :
|
||||
VcInputMode.VoiceActivation;
|
||||
|
||||
private float VadThresholdFromSlider() =>
|
||||
0.1f * (1f - (_trkVad.Value - 1f) / 99f);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public sealed class ChannelEditDialog : Form
|
||||
private CheckBox _chkFec = null!;
|
||||
private NumericUpDown _numExpectedLoss = null!;
|
||||
private CheckBox _chkDtx = null!;
|
||||
private CheckBox _chkDred = null!;
|
||||
private NumericUpDown _numComplexity = null!;
|
||||
|
||||
public ChannelEditInfo? Result { get; private set; }
|
||||
@@ -183,7 +184,7 @@ public sealed class ChannelEditDialog : Form
|
||||
|
||||
private void BuildAudioPage(TabPage page, AudioConfigInfo? audio)
|
||||
{
|
||||
audio ??= new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10);
|
||||
audio ??= new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10, false);
|
||||
|
||||
int y = 16;
|
||||
int labelWidth = 150;
|
||||
@@ -314,6 +315,17 @@ public sealed class ChannelEditDialog : Form
|
||||
TabIndex = 18,
|
||||
};
|
||||
page.Controls.Add(_chkDtx);
|
||||
y += 28;
|
||||
|
||||
_chkDred = new CheckBox
|
||||
{
|
||||
Text = "D&RED (deep redundancy)",
|
||||
Location = new Point(inputX, y),
|
||||
AutoSize = true,
|
||||
Checked = audio.Dred,
|
||||
TabIndex = 19,
|
||||
};
|
||||
page.Controls.Add(_chkDred);
|
||||
}
|
||||
|
||||
private static void AddLabel(Control parent, string text, int x, int y, int width)
|
||||
@@ -362,7 +374,8 @@ public sealed class ChannelEditDialog : Form
|
||||
Fec: _chkFec.Checked,
|
||||
ExpectedPacketLoss: (uint)_numExpectedLoss.Value,
|
||||
Dtx: _chkDtx.Checked,
|
||||
Complexity: (uint)_numComplexity.Value);
|
||||
Complexity: (uint)_numComplexity.Value,
|
||||
Dred: _chkDred.Checked);
|
||||
|
||||
Result = new ChannelEditInfo(
|
||||
Id: _editingId,
|
||||
|
||||
@@ -19,6 +19,7 @@ public partial class ConnectDialog : Form
|
||||
public VoiceCatClient? ConnectedClient { get; private set; }
|
||||
public uint SelfUserId { get; private set; }
|
||||
public string Nickname { get; private set; } = "";
|
||||
public string ServerName { get; private set; } = "";
|
||||
|
||||
public ConnectDialog()
|
||||
{
|
||||
@@ -91,13 +92,8 @@ public partial class ConnectDialog : Form
|
||||
|
||||
private void BtnConnect_Click(object? sender, EventArgs e)
|
||||
{
|
||||
Console.WriteLine("[ConnectDialog] BtnConnect_Click fired");
|
||||
if (lstServers.SelectedItem is not SavedServer server)
|
||||
{
|
||||
Console.WriteLine("[ConnectDialog] no SavedServer selected — ignoring click");
|
||||
return;
|
||||
}
|
||||
Console.WriteLine($"[ConnectDialog] selected server: Host={server.Host} Port={server.Port} AuthMode={server.AuthMode}");
|
||||
try
|
||||
{
|
||||
StartConnect(server);
|
||||
@@ -115,22 +111,20 @@ public partial class ConnectDialog : Form
|
||||
SetBusy(true);
|
||||
lblStatus.Text = "Connecting...";
|
||||
|
||||
ServerName = string.IsNullOrWhiteSpace(server.DisplayName)
|
||||
? $"{server.Host}:{server.Port}"
|
||||
: server.DisplayName;
|
||||
|
||||
string tofuDir = Path.GetDirectoryName(ServerListStore.TofuStorePath)!;
|
||||
Console.WriteLine($"[ConnectDialog] tofu store dir: {tofuDir}");
|
||||
Directory.CreateDirectory(tofuDir);
|
||||
|
||||
Console.WriteLine("[ConnectDialog] creating VoiceCatClient...");
|
||||
_client = new VoiceCatClient("VoiceCat-Windows", VoiceCatClient.VersionString,
|
||||
VcLogLevel.Info, ServerListStore.TofuStorePath);
|
||||
Console.WriteLine("[ConnectDialog] VoiceCatClient created OK");
|
||||
_client.EventReceived += OnEvent;
|
||||
_identityDialogShown = false;
|
||||
_pumpTimer.Start();
|
||||
Console.WriteLine($"[ConnectDialog] pump timer started, Enabled={_pumpTimer.Enabled}, Interval={_pumpTimer.Interval}");
|
||||
|
||||
Console.WriteLine($"[ConnectDialog] calling Connect({server.Host}, {server.Port})...");
|
||||
var connectResult = _client.Connect(server.Host, server.Port);
|
||||
Console.WriteLine($"[ConnectDialog] Connect() returned {connectResult}");
|
||||
if (connectResult != VcResult.Ok)
|
||||
{
|
||||
lblStatus.Text = $"Connect failed: {connectResult}";
|
||||
@@ -141,9 +135,7 @@ public partial class ConnectDialog : Form
|
||||
if (server.AuthMode == AuthMode.Guest)
|
||||
{
|
||||
Nickname = string.IsNullOrWhiteSpace(server.LastNickname) ? Environment.UserName : server.LastNickname;
|
||||
Console.WriteLine($"[ConnectDialog] calling AuthenticateGuest({Nickname})...");
|
||||
var authResult = _client.AuthenticateGuest(Nickname);
|
||||
Console.WriteLine($"[ConnectDialog] AuthenticateGuest() returned {authResult}");
|
||||
_client.AuthenticateGuest(Nickname);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -164,15 +156,12 @@ public partial class ConnectDialog : Form
|
||||
password = pwDlg.Password;
|
||||
}
|
||||
Nickname = server.SavedUsername ?? "";
|
||||
Console.WriteLine($"[ConnectDialog] calling AuthenticateUser({Nickname})...");
|
||||
var authResult = _client.AuthenticateUser(server.SavedUsername ?? "", password);
|
||||
Console.WriteLine($"[ConnectDialog] AuthenticateUser() returned {authResult}");
|
||||
_client.AuthenticateUser(server.SavedUsername ?? "", password);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEvent(VoiceCatEvent ev)
|
||||
{
|
||||
Console.WriteLine($"[ConnectDialog] event: {ev}");
|
||||
switch (ev.Type)
|
||||
{
|
||||
case VcEventType.ConnectionState:
|
||||
@@ -220,24 +209,18 @@ public partial class ConnectDialog : Form
|
||||
|
||||
private void HandleServerIdentity(VcTofuStatus status, string certFingerprintHex)
|
||||
{
|
||||
Console.WriteLine($"[ConnectDialog] HandleServerIdentity status={status} fp={certFingerprintHex} alreadyShown={_identityDialogShown}");
|
||||
if (_identityDialogShown) return; // one decision per connect attempt
|
||||
if (status == VcTofuStatus.Matched)
|
||||
{
|
||||
// Silent success path — no dialog. See ServerIdentityDialog's doc comment.
|
||||
Console.WriteLine("[ConnectDialog] status=Matched -> auto-confirming, no dialog");
|
||||
_client!.ConfirmServerIdentity(true);
|
||||
return;
|
||||
}
|
||||
|
||||
_identityDialogShown = true;
|
||||
Console.WriteLine("[ConnectDialog] showing ServerIdentityDialog...");
|
||||
using var dlg = new ServerIdentityDialog(status, certFingerprintHex, _client!.GetServerIdentityDisplay());
|
||||
var dlgResult = dlg.ShowDialog(this);
|
||||
Console.WriteLine($"[ConnectDialog] ServerIdentityDialog closed with {dlgResult}");
|
||||
bool accept = dlgResult == DialogResult.OK;
|
||||
var confirmResult = _client.ConfirmServerIdentity(accept);
|
||||
Console.WriteLine($"[ConnectDialog] ConfirmServerIdentity({accept}) returned {confirmResult}");
|
||||
bool accept = dlg.ShowDialog(this) == DialogResult.OK;
|
||||
_client.ConfirmServerIdentity(accept);
|
||||
if (!accept) lblStatus.Text = "Server identity rejected.";
|
||||
}
|
||||
|
||||
|
||||
+17
-112
@@ -36,21 +36,10 @@ partial class MainForm
|
||||
// Voice control panel (docked Bottom)
|
||||
private Panel pnlVoice = null!;
|
||||
private FlowLayoutPanel flpVoiceTop = null!;
|
||||
private FlowLayoutPanel flpVoiceBottom = null!;
|
||||
private CheckBox chkMute = null!;
|
||||
private CheckBox chkDeafen = null!;
|
||||
private RadioButton radioVad = null!;
|
||||
private RadioButton radioPtt = null!;
|
||||
private RadioButton radioAlwaysOn = null!;
|
||||
private Label lblPttKey = null!;
|
||||
private Button btnChangePtt = null!;
|
||||
private Label lblInputDevice = null!;
|
||||
private ComboBox cboInputDevice = null!;
|
||||
private Button btnRefreshDevices = null!;
|
||||
private Label lblLevel = null!;
|
||||
private ProgressBar pbLevel = null!;
|
||||
private Label lblVadThreshold = null!;
|
||||
private TrackBar trkVadThreshold = null!;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
@@ -79,21 +68,10 @@ partial class MainForm
|
||||
trkOutputVolume = new TrackBar();
|
||||
pnlVoice = new Panel();
|
||||
flpVoiceTop = new FlowLayoutPanel();
|
||||
flpVoiceBottom = new FlowLayoutPanel();
|
||||
chkMute = new CheckBox();
|
||||
chkDeafen = new CheckBox();
|
||||
radioVad = new RadioButton();
|
||||
radioPtt = new RadioButton();
|
||||
radioAlwaysOn = new RadioButton();
|
||||
lblPttKey = new Label();
|
||||
btnChangePtt = new Button();
|
||||
lblInputDevice = new Label();
|
||||
cboInputDevice = new ComboBox();
|
||||
btnRefreshDevices = new Button();
|
||||
lblLevel = new Label();
|
||||
pbLevel = new ProgressBar();
|
||||
lblVadThreshold = new Label();
|
||||
trkVadThreshold = new TrackBar();
|
||||
menuStrip = new MenuStrip();
|
||||
toolStrip = new ToolStrip();
|
||||
tsbJoinVoice = new ToolStripButton();
|
||||
@@ -140,6 +118,13 @@ partial class MainForm
|
||||
splitLeft.Panel1MinSize = 100;
|
||||
splitLeft.Panel2MinSize = 80;
|
||||
splitLeft.TabIndex = 0;
|
||||
// Keep the resize splitter out of the Tab cycle so focus moves control-to-control.
|
||||
splitLeft.TabStop = false;
|
||||
// Name the otherwise-anonymous "pane" containers so screen readers announce
|
||||
// orientation instead of a stack of unnamed panes.
|
||||
splitLeft.AccessibleName = "Channels and users";
|
||||
splitLeft.Panel1.AccessibleName = "Channels";
|
||||
splitLeft.Panel2.AccessibleName = "Users";
|
||||
splitLeft.Panel1.Controls.Add(tvChannels);
|
||||
splitLeft.Panel1.Controls.Add(lblChannels);
|
||||
splitLeft.Panel2.Controls.Add(lstUsers);
|
||||
@@ -214,6 +199,10 @@ partial class MainForm
|
||||
splitMain.Dock = DockStyle.Fill;
|
||||
splitMain.Panel1MinSize = 150;
|
||||
splitMain.TabIndex = 1;
|
||||
splitMain.TabStop = false;
|
||||
splitMain.AccessibleName = "Main";
|
||||
splitMain.Panel1.AccessibleName = "Channels and users";
|
||||
splitMain.Panel2.AccessibleName = "Chat and activity";
|
||||
splitMain.Panel1.Controls.Add(splitLeft);
|
||||
splitMain.Panel2.Controls.Add(tblRight);
|
||||
|
||||
@@ -230,71 +219,15 @@ partial class MainForm
|
||||
chkDeafen.Margin = new Padding(0, 4, 12, 0);
|
||||
chkDeafen.TabIndex = 2;
|
||||
|
||||
var lblMode = new Label { Text = "Mode:", AutoSize = true, Margin = new Padding(0, 5, 4, 0) };
|
||||
|
||||
radioVad.Text = "&Voice activation";
|
||||
radioVad.AutoSize = true;
|
||||
radioVad.Checked = true;
|
||||
radioVad.Enabled = false;
|
||||
radioVad.Margin = new Padding(0, 4, 6, 0);
|
||||
radioVad.TabIndex = 3;
|
||||
|
||||
radioPtt.Text = "&Push to talk";
|
||||
radioPtt.AutoSize = true;
|
||||
radioPtt.Enabled = false;
|
||||
radioPtt.Margin = new Padding(0, 4, 4, 0);
|
||||
radioPtt.TabIndex = 4;
|
||||
|
||||
radioAlwaysOn.Text = "A&lways on";
|
||||
radioAlwaysOn.AutoSize = true;
|
||||
radioAlwaysOn.Enabled = false;
|
||||
radioAlwaysOn.Margin = new Padding(0, 4, 12, 0);
|
||||
radioAlwaysOn.TabIndex = 5;
|
||||
|
||||
lblPttKey.Text = "(F8)";
|
||||
lblPttKey.AutoSize = true;
|
||||
lblPttKey.Margin = new Padding(2, 5, 4, 0);
|
||||
lblPttKey.Visible = false;
|
||||
|
||||
btnChangePtt.Text = "Change key...";
|
||||
btnChangePtt.AutoSize = true;
|
||||
btnChangePtt.Margin = new Padding(0, 2, 0, 0);
|
||||
btnChangePtt.Visible = false;
|
||||
btnChangePtt.TabIndex = 6;
|
||||
|
||||
flpVoiceTop.Dock = DockStyle.Top;
|
||||
flpVoiceTop.Height = 34;
|
||||
flpVoiceTop.Dock = DockStyle.Fill;
|
||||
flpVoiceTop.AutoSize = false;
|
||||
flpVoiceTop.Padding = new Padding(4, 2, 4, 0);
|
||||
flpVoiceTop.Controls.Add(chkMute);
|
||||
flpVoiceTop.Controls.Add(chkDeafen);
|
||||
flpVoiceTop.Controls.Add(lblMode);
|
||||
flpVoiceTop.Controls.Add(radioVad);
|
||||
flpVoiceTop.Controls.Add(radioPtt);
|
||||
flpVoiceTop.Controls.Add(radioAlwaysOn);
|
||||
flpVoiceTop.Controls.Add(lblPttKey);
|
||||
flpVoiceTop.Controls.Add(btnChangePtt);
|
||||
|
||||
// Bottom row: device picker + level meter
|
||||
lblInputDevice.Text = "Input:";
|
||||
lblInputDevice.AutoSize = true;
|
||||
lblInputDevice.Margin = new Padding(0, 5, 4, 0);
|
||||
|
||||
cboInputDevice.AccessibleName = "Input device";
|
||||
cboInputDevice.AccessibleDescription = "Select which microphone or audio device to use.";
|
||||
cboInputDevice.DropDownStyle = ComboBoxStyle.DropDownList;
|
||||
cboInputDevice.Width = 200;
|
||||
cboInputDevice.Margin = new Padding(0, 2, 4, 0);
|
||||
cboInputDevice.TabIndex = 7;
|
||||
|
||||
btnRefreshDevices.Text = "Re&fresh";
|
||||
btnRefreshDevices.AutoSize = true;
|
||||
btnRefreshDevices.Margin = new Padding(0, 2, 12, 0);
|
||||
btnRefreshDevices.TabIndex = 8;
|
||||
|
||||
lblLevel.Text = "Level:";
|
||||
lblLevel.AutoSize = true;
|
||||
lblLevel.Margin = new Padding(0, 5, 4, 0);
|
||||
lblLevel.Margin = new Padding(12, 5, 4, 0);
|
||||
|
||||
pbLevel.AccessibleName = "Microphone level";
|
||||
pbLevel.AccessibleDescription = "Current input level from the microphone.";
|
||||
@@ -305,42 +238,14 @@ partial class MainForm
|
||||
pbLevel.Style = ProgressBarStyle.Continuous;
|
||||
pbLevel.TabStop = false;
|
||||
|
||||
lblVadThreshold.Text = "Sensitivity:";
|
||||
lblVadThreshold.AutoSize = true;
|
||||
lblVadThreshold.Margin = new Padding(12, 5, 4, 0);
|
||||
lblVadThreshold.Visible = true;
|
||||
|
||||
trkVadThreshold.AccessibleName = "VAD sensitivity";
|
||||
trkVadThreshold.AccessibleDescription =
|
||||
"Voice detection sensitivity. Higher = more sensitive (triggers on quieter sounds). " +
|
||||
"Range 1–100; default 25.";
|
||||
trkVadThreshold.Minimum = 1;
|
||||
trkVadThreshold.Maximum = 100;
|
||||
trkVadThreshold.Value = 76;
|
||||
trkVadThreshold.TickFrequency = 10;
|
||||
trkVadThreshold.SmallChange = 1;
|
||||
trkVadThreshold.LargeChange = 10;
|
||||
trkVadThreshold.Width = 120;
|
||||
trkVadThreshold.Margin = new Padding(0, 2, 0, 0);
|
||||
trkVadThreshold.TabIndex = 9;
|
||||
trkVadThreshold.Visible = true;
|
||||
|
||||
flpVoiceBottom.Dock = DockStyle.Fill;
|
||||
flpVoiceBottom.Padding = new Padding(4, 0, 4, 2);
|
||||
flpVoiceBottom.Controls.Add(lblInputDevice);
|
||||
flpVoiceBottom.Controls.Add(cboInputDevice);
|
||||
flpVoiceBottom.Controls.Add(btnRefreshDevices);
|
||||
flpVoiceBottom.Controls.Add(lblLevel);
|
||||
flpVoiceBottom.Controls.Add(pbLevel);
|
||||
flpVoiceBottom.Controls.Add(lblVadThreshold);
|
||||
flpVoiceBottom.Controls.Add(trkVadThreshold);
|
||||
flpVoiceTop.Controls.Add(lblLevel);
|
||||
flpVoiceTop.Controls.Add(pbLevel);
|
||||
|
||||
pnlVoice.Dock = DockStyle.Bottom;
|
||||
pnlVoice.Height = 68;
|
||||
pnlVoice.Height = 38;
|
||||
pnlVoice.BorderStyle = BorderStyle.FixedSingle;
|
||||
pnlVoice.Padding = new Padding(0);
|
||||
pnlVoice.Controls.Add(flpVoiceBottom); // Fill — added first
|
||||
pnlVoice.Controls.Add(flpVoiceTop); // Top — added last
|
||||
pnlVoice.Controls.Add(flpVoiceTop);
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────────
|
||||
tsbJoinVoice.Text = "Join Voice";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
using VoiceCat.App.Audio;
|
||||
using VoiceCat.App.Models;
|
||||
using VoiceCat.App.Native;
|
||||
using VoiceCat.App.Notifications;
|
||||
using VoiceCat.Interop;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
@@ -11,6 +15,8 @@ public partial class MainForm : Form
|
||||
private readonly uint _selfUserId;
|
||||
private readonly string _nickname;
|
||||
private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 };
|
||||
private readonly EventFeedback _feedback = new(FeedbackSettings.Load());
|
||||
private readonly VoiceSettings _voiceSettings = VoiceSettings.Load();
|
||||
|
||||
// Channel / user state
|
||||
private uint _currentChannelId;
|
||||
@@ -22,7 +28,12 @@ public partial class MainForm : Form
|
||||
// Voice state
|
||||
private uint _micStreamId; // 0 = not started
|
||||
private uint _screenStreamId; // 0 = not sharing screen audio
|
||||
private ProcessAudioMixer? _screenMixer; // non-null only in per-app capture mode
|
||||
private uint _auxStreamId; // 0 = aux (second input device) stream not active
|
||||
private InputDeviceCapture? _auxCapture; // client-side capture feeding the aux stream
|
||||
private Keys _pttKey = Keys.F8;
|
||||
private bool _pttEngaged; // guards the PTT cue against key-repeat
|
||||
private bool _rawInputRegistered; // true while the system-wide PTT keyboard sink is active
|
||||
private bool _serverMuted;
|
||||
private bool _serverDeafened;
|
||||
|
||||
@@ -33,18 +44,21 @@ public partial class MainForm : Form
|
||||
private ToolStripMenuItem _miJoinVoice = null!;
|
||||
private ToolStripMenuItem _miScreenShare = null!;
|
||||
|
||||
public MainForm(VoiceCatClient client, uint selfUserId, string nickname)
|
||||
public MainForm(VoiceCatClient client, uint selfUserId, string nickname, string serverName)
|
||||
{
|
||||
InitializeComponent();
|
||||
_client = client;
|
||||
_selfUserId = selfUserId;
|
||||
_nickname = nickname;
|
||||
|
||||
Text = $"VoiceCat — {nickname}";
|
||||
Text = string.IsNullOrWhiteSpace(serverName)
|
||||
? $"VoiceCat — {nickname}"
|
||||
: $"VoiceCat — {nickname} @ {serverName}";
|
||||
|
||||
_client.EventReceived += OnEvent;
|
||||
_client.LevelChanged += OnLevelChanged;
|
||||
_pumpTimer.Tick += (_, _) => _client.PumpEvents();
|
||||
_pumpTimer.Tick += (_, _) => PttWatchdog();
|
||||
|
||||
_ownPermissions = SafeGetPermissions();
|
||||
BuildMenus();
|
||||
@@ -60,7 +74,7 @@ public partial class MainForm : Form
|
||||
tvChannels.DoubleClick += TvChannels_DoubleClick;
|
||||
tvChannels.KeyDown += TvChannels_KeyDown;
|
||||
lstUsers.DoubleClick += (_, _) => OpenUserTuning();
|
||||
lstUsers.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) OpenUserTuning(); };
|
||||
lstUsers.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) { OpenUserTuning(); e.Handled = e.SuppressKeyPress = true; } };
|
||||
|
||||
// Compose
|
||||
txtCompose.KeyDown += TxtCompose_KeyDown;
|
||||
@@ -76,26 +90,28 @@ public partial class MainForm : Form
|
||||
// Voice controls
|
||||
chkMute.CheckedChanged += (_, _) => ApplySelfMute();
|
||||
chkDeafen.CheckedChanged += (_, _) => ApplySelfMute();
|
||||
radioVad.CheckedChanged += RadioVad_CheckedChanged;
|
||||
radioPtt.CheckedChanged += RadioPtt_CheckedChanged;
|
||||
radioAlwaysOn.CheckedChanged += RadioAlwaysOn_CheckedChanged;
|
||||
trkVadThreshold.Scroll += TrkVadThreshold_Scroll;
|
||||
btnChangePtt.Click += BtnChangePtt_Click;
|
||||
btnRefreshDevices.Click += (_, _) => LoadInputDevices();
|
||||
cboInputDevice.SelectedIndexChanged += CboInputDevice_SelectedIndexChanged;
|
||||
|
||||
// PTT and global hotkeys (focus-scoped — work only while this form has focus)
|
||||
// PTT and global hotkeys. The mute/deafen/screen-share toggles (MainForm_HotkeyDown) are
|
||||
// always focus-scoped. PTT is focus-scoped via KeyDown/KeyUp when system-wide PTT is OFF;
|
||||
// when ON, the WM_INPUT path (WndProc + Raw Input) owns PTT for both focused and unfocused
|
||||
// cases and the KeyDown/KeyUp handlers early-return.
|
||||
KeyDown += MainForm_KeyDown;
|
||||
KeyDown += MainForm_HotkeyDown;
|
||||
KeyUp += MainForm_KeyUp;
|
||||
Deactivate += (_, _) =>
|
||||
{
|
||||
if (_micStreamId != 0) _client.SetPushToTalk(false);
|
||||
// With system-wide PTT we WANT transmission to continue while unfocused, so don't
|
||||
// release on deactivate — the Raw Input key-up (and PttWatchdog) handle release.
|
||||
if (!_voiceSettings.SystemWidePtt && _micStreamId != 0) _client.SetPushToTalk(false);
|
||||
};
|
||||
|
||||
ApplyPersistedVoiceSettings();
|
||||
BootstrapFromServer();
|
||||
}
|
||||
|
||||
private void ApplyPersistedVoiceSettings() =>
|
||||
_pttKey = (Keys)_voiceSettings.PttKey;
|
||||
|
||||
// ── Startup ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void BootstrapFromServer()
|
||||
@@ -116,6 +132,8 @@ public partial class MainForm : Form
|
||||
RefreshUserList();
|
||||
UpdateStatusLabel();
|
||||
AddActivity($"Connected to server as {_nickname}");
|
||||
_feedback.PlaySound(SoundEvent.Login);
|
||||
_feedback.Speak("Connected");
|
||||
}
|
||||
|
||||
protected override void OnLoad(EventArgs e)
|
||||
@@ -123,7 +141,6 @@ public partial class MainForm : Form
|
||||
base.OnLoad(e);
|
||||
splitMain.SplitterDistance = Math.Min(220, splitMain.Width - 304);
|
||||
splitLeft.SplitterDistance = Math.Min(260, splitLeft.Height - 84);
|
||||
LoadInputDevices();
|
||||
}
|
||||
|
||||
// ── Menu / context menu builders ─────────────────────────────────────────
|
||||
@@ -159,6 +176,35 @@ public partial class MainForm : Form
|
||||
messagesMenu.DropDownItems.Add(miNewPm);
|
||||
menuStrip.Items.Add(messagesMenu);
|
||||
|
||||
// Settings menu — always visible
|
||||
var settingsMenu = new ToolStripMenuItem("&Settings");
|
||||
var miAudio = new ToolStripMenuItem("&Audio...");
|
||||
miAudio.Click += (_, _) =>
|
||||
{
|
||||
using var dlg = new AudioSettingsForm(_client, _voiceSettings, _micStreamId,
|
||||
applyAuxEnabled: on =>
|
||||
{
|
||||
if (_micStreamId == 0) return; // not in voice — applied on next Join Voice
|
||||
if (on) StartAuxStream(); else StopAuxStream();
|
||||
},
|
||||
applyAuxDevice: _ =>
|
||||
{
|
||||
if (_auxStreamId != 0) RestartAuxCapture(); // settings.AuxDeviceId already updated
|
||||
});
|
||||
dlg.ShowDialog(this);
|
||||
_pttKey = (Keys)_voiceSettings.PttKey;
|
||||
ApplySystemWidePtt(); // the system-wide toggle may have changed
|
||||
};
|
||||
settingsMenu.DropDownItems.Add(miAudio);
|
||||
var miNotifications = new ToolStripMenuItem("&Notifications...");
|
||||
miNotifications.Click += (_, _) =>
|
||||
{
|
||||
using var dlg = new NotificationSettingsForm(_feedback);
|
||||
dlg.ShowDialog(this);
|
||||
};
|
||||
settingsMenu.DropDownItems.Add(miNotifications);
|
||||
menuStrip.Items.Add(settingsMenu);
|
||||
|
||||
// Admin menu — only if permitted
|
||||
if (_ownPermissions.CanAdminAccounts)
|
||||
{
|
||||
@@ -199,6 +245,7 @@ public partial class MainForm : Form
|
||||
ctx.Items.Add("&Delete channel...", null, (_, _) => DeleteSelectedChannel());
|
||||
}
|
||||
};
|
||||
ctx.Opened += (_, _) => SelectFirstMenuItem(ctx);
|
||||
tvChannels.ContextMenuStrip = ctx;
|
||||
}
|
||||
|
||||
@@ -245,9 +292,26 @@ public partial class MainForm : Form
|
||||
}
|
||||
}
|
||||
};
|
||||
ctx.Opened += (_, _) => SelectFirstMenuItem(ctx);
|
||||
lstUsers.ContextMenuStrip = ctx;
|
||||
}
|
||||
|
||||
// Work around the WinForms ContextMenuStrip accessibility bug: when opened by
|
||||
// keyboard (Shift+F10 / Apps key) the focused item is not set, so screen readers
|
||||
// stay silent until the first arrow key. Selecting the first item ourselves on
|
||||
// Opened raises the UIA focus event immediately.
|
||||
private static void SelectFirstMenuItem(ContextMenuStrip ctx)
|
||||
{
|
||||
foreach (ToolStripItem item in ctx.Items)
|
||||
{
|
||||
if (item is ToolStripMenuItem && item.Enabled)
|
||||
{
|
||||
item.Select();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event dispatch ────────────────────────────────────────────────────────
|
||||
|
||||
private void OnEvent(VoiceCatEvent ev)
|
||||
@@ -285,10 +349,21 @@ public partial class MainForm : Form
|
||||
HandleStreamStarted(ev);
|
||||
break;
|
||||
case VcEventType.StreamStopped:
|
||||
if (_users.TryGetValue(ev.UserId, out var stUser) &&
|
||||
if (ev.UserId == _selfUserId)
|
||||
{
|
||||
if (ev.StreamId == _micStreamId)
|
||||
{
|
||||
_micStreamId = 0;
|
||||
pbLevel.Value = 0;
|
||||
}
|
||||
}
|
||||
else if (_users.TryGetValue(ev.UserId, out var stUser) &&
|
||||
stUser.ChannelId == _currentChannelId)
|
||||
AddActivity($"{stUser.Nickname} stopped a stream");
|
||||
break;
|
||||
case VcEventType.VoiceState:
|
||||
HandleVoiceState(ev);
|
||||
break;
|
||||
case VcEventType.Disconnected:
|
||||
HandleDisconnected(ev);
|
||||
break;
|
||||
@@ -314,12 +389,16 @@ public partial class MainForm : Form
|
||||
private void HandleUserJoined(VoiceCatEvent ev)
|
||||
{
|
||||
var user = new UserInfo(ev.UserId, ev.Text ?? $"User#{ev.UserId}", false, ev.ChannelId,
|
||||
false, false, false, false);
|
||||
false, false, false, false, false);
|
||||
_users[ev.UserId] = user;
|
||||
RefreshChannelTree();
|
||||
RefreshUserList();
|
||||
if (ev.ChannelId == _currentChannelId && ev.UserId != _selfUserId)
|
||||
{
|
||||
AddActivity($"{user.Nickname} joined the channel");
|
||||
_feedback.PlaySound(SoundEvent.ChannelJoin);
|
||||
_feedback.Speak($"{user.Nickname} joined");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleUserLeft(VoiceCatEvent ev)
|
||||
@@ -330,7 +409,12 @@ public partial class MainForm : Form
|
||||
_talkingUsers.Remove(ev.UserId);
|
||||
RefreshChannelTree();
|
||||
RefreshUserList();
|
||||
if (wasHere) AddActivity($"{user.Nickname} left the channel");
|
||||
if (wasHere)
|
||||
{
|
||||
AddActivity($"{user.Nickname} left the channel");
|
||||
_feedback.PlaySound(SoundEvent.ChannelLeave);
|
||||
_feedback.Speak($"{user.Nickname} left");
|
||||
}
|
||||
if (_pmWindows.TryGetValue(ev.UserId, out var pmWin))
|
||||
pmWin.AppendActivity($"{user.Nickname} disconnected from server");
|
||||
}
|
||||
@@ -386,18 +470,23 @@ public partial class MainForm : Form
|
||||
.LocalDateTime.ToString("HH:mm")
|
||||
: DateTime.Now.ToString("HH:mm");
|
||||
string sender = GetNickname(ev.UserId);
|
||||
bool isSelf = ev.UserId == _selfUserId;
|
||||
string body = ev.Text ?? "";
|
||||
|
||||
if (ev.TextScope == VcTextScope.Private)
|
||||
{
|
||||
// For our own outgoing PM, ev.ChannelId carries the recipient user ID.
|
||||
uint otherUserId = ev.UserId == _selfUserId ? ev.ChannelId : ev.UserId;
|
||||
uint otherUserId = isSelf ? ev.ChannelId : ev.UserId;
|
||||
var win = GetOrOpenPmWindow(otherUserId);
|
||||
bool isSelf = ev.UserId == _selfUserId;
|
||||
win.AppendMessage(time, isSelf, sender, ev.Text ?? "");
|
||||
win.AppendMessage(time, isSelf, sender, body);
|
||||
_feedback.PlaySound(isSelf ? SoundEvent.PmSent : SoundEvent.PmRecv);
|
||||
if (!isSelf) _feedback.Speak($"Private message from {sender}: {body}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendChat(time, sender, ev.Text ?? "");
|
||||
AppendChat(time, sender, body);
|
||||
_feedback.PlaySound(isSelf ? SoundEvent.ChannelSent : SoundEvent.ChannelRecv);
|
||||
if (!isSelf) _feedback.Speak($"{sender}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +496,8 @@ public partial class MainForm : Form
|
||||
if (talking) _talkingUsers.Add(ev.UserId);
|
||||
else _talkingUsers.Remove(ev.UserId);
|
||||
RefreshUserList();
|
||||
if (ev.UserId == _selfUserId)
|
||||
_feedback.PlaySound(talking ? SoundEvent.VaStart : SoundEvent.VaStop);
|
||||
if (talking && ev.UserId != _selfUserId &&
|
||||
_users.TryGetValue(ev.UserId, out var tUser) &&
|
||||
tUser.ChannelId == _currentChannelId)
|
||||
@@ -435,6 +526,16 @@ public partial class MainForm : Form
|
||||
: $"Disconnected: {ev.Text}";
|
||||
lblStatus.Text = msg;
|
||||
AddActivity(msg);
|
||||
if (ev.Result == VcResult.Ok)
|
||||
{
|
||||
_feedback.PlaySound(SoundEvent.Logout);
|
||||
_feedback.Speak("Disconnected");
|
||||
}
|
||||
else
|
||||
{
|
||||
_feedback.PlaySound(SoundEvent.ConnectionLost);
|
||||
_feedback.Speak("Connection lost");
|
||||
}
|
||||
tvChannels.Nodes.Clear();
|
||||
lstUsers.Items.Clear();
|
||||
_users.Clear();
|
||||
@@ -442,6 +543,8 @@ public partial class MainForm : Form
|
||||
_currentChannelId = 0;
|
||||
_micStreamId = 0;
|
||||
_screenStreamId = 0;
|
||||
_screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null;
|
||||
DisposeAuxCapture(); _auxStreamId = 0; // connection gone — drop capture, no StopStream
|
||||
txtCompose.Enabled = false;
|
||||
btnSend.Enabled = false;
|
||||
tsbJoinVoice.Enabled = false;
|
||||
@@ -465,6 +568,7 @@ public partial class MainForm : Form
|
||||
private void RefreshChannelTree()
|
||||
{
|
||||
uint toSelect = tvChannels.SelectedNode?.Tag is uint s ? s : _currentChannelId;
|
||||
bool hadFocus = tvChannels.Focused;
|
||||
tvChannels.BeginUpdate();
|
||||
tvChannels.Nodes.Clear();
|
||||
|
||||
@@ -490,6 +594,18 @@ public partial class MainForm : Form
|
||||
tvChannels.ExpandAll();
|
||||
SeekAndSelect(tvChannels.Nodes, toSelect);
|
||||
tvChannels.EndUpdate();
|
||||
|
||||
// A Nodes.Clear()/rebuild can drop keyboard focus and leave the screen reader
|
||||
// without a current node. If the tree was focused before the refresh, restore
|
||||
// focus and re-announce the now-current node (null-then-reselect forces UIA to
|
||||
// fire a fresh focus event).
|
||||
if (hadFocus && tvChannels.SelectedNode != null)
|
||||
{
|
||||
var node = tvChannels.SelectedNode;
|
||||
tvChannels.Focus();
|
||||
tvChannels.SelectedNode = null;
|
||||
tvChannels.SelectedNode = node;
|
||||
}
|
||||
}
|
||||
|
||||
private bool SeekAndSelect(TreeNodeCollection nodes, uint channelId)
|
||||
@@ -505,6 +621,11 @@ public partial class MainForm : Form
|
||||
|
||||
private void RefreshUserList()
|
||||
{
|
||||
// Preserve the keyboard selection across the rebuild: clearing the list resets
|
||||
// SelectedIndex to -1, which would throw focus around every time a talking/mute
|
||||
// indicator toggles. Capture the selected user id and reselect it afterwards.
|
||||
uint? prevSel = (lstUsers.SelectedItem as UserListItem)?.UserId;
|
||||
|
||||
lstUsers.BeginUpdate();
|
||||
lstUsers.Items.Clear();
|
||||
foreach (var user in _users.Values
|
||||
@@ -518,6 +639,15 @@ public partial class MainForm : Form
|
||||
if (user.SelfDeafened || user.ServerDeafened) label += " (deafened)";
|
||||
lstUsers.Items.Add(new UserListItem(user.Id, label));
|
||||
}
|
||||
if (prevSel is uint sel)
|
||||
{
|
||||
for (int i = 0; i < lstUsers.Items.Count; i++)
|
||||
if (lstUsers.Items[i] is UserListItem item && item.UserId == sel)
|
||||
{
|
||||
lstUsers.SelectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
lstUsers.EndUpdate();
|
||||
UpdateStatusLabel();
|
||||
}
|
||||
@@ -539,68 +669,22 @@ public partial class MainForm : Form
|
||||
lblStatus.Text = $"Connected as {_nickname}{suffix} — {chanName} ({count} user{(count == 1 ? "" : "s")})";
|
||||
}
|
||||
|
||||
// ── Device management ─────────────────────────────────────────────────────
|
||||
|
||||
private void LoadInputDevices()
|
||||
{
|
||||
var devices = _client.ListDevices(VcDeviceKind.Input);
|
||||
DeviceInfo? prevDevice = cboInputDevice.SelectedItem as DeviceInfo;
|
||||
|
||||
cboInputDevice.Items.Clear();
|
||||
foreach (var d in devices) cboInputDevice.Items.Add(d);
|
||||
|
||||
if (prevDevice is not null)
|
||||
{
|
||||
for (int i = 0; i < cboInputDevice.Items.Count; i++)
|
||||
{
|
||||
if (cboInputDevice.Items[i] is DeviceInfo d && d.Id == prevDevice.Id)
|
||||
{
|
||||
cboInputDevice.SelectedIndex = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < cboInputDevice.Items.Count; i++)
|
||||
{
|
||||
if (cboInputDevice.Items[i] is DeviceInfo d && d.IsDefault)
|
||||
{
|
||||
cboInputDevice.SelectedIndex = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (cboInputDevice.Items.Count > 0) cboInputDevice.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
// ── Voice controls ────────────────────────────────────────────────────────
|
||||
|
||||
private void BtnMicToggle_Click(object? sender, EventArgs e)
|
||||
{
|
||||
if (_micStreamId == 0)
|
||||
{
|
||||
var (result, streamId) = _client.StartStream(VcStreamKind.Mic, "Microphone");
|
||||
if (result == VcResult.Ok)
|
||||
{
|
||||
_micStreamId = streamId;
|
||||
if (cboInputDevice.SelectedItem is DeviceInfo { IsDefault: false } dev)
|
||||
_client.SetInputDevice(streamId, dev.Id);
|
||||
_client.SetInputMode(CurrentInputMode());
|
||||
if (radioVad.Checked) _client.SetVadThreshold(VadThresholdFromSlider());
|
||||
SetVoiceJoinedState(true);
|
||||
AddActivity("Joined voice — microphone active");
|
||||
}
|
||||
else
|
||||
{
|
||||
AddActivity($"Failed to start microphone: {result}");
|
||||
}
|
||||
var result = _client.JoinVoice();
|
||||
if (result != VcResult.Ok)
|
||||
AddActivity($"Failed to join voice: {result}");
|
||||
}
|
||||
else
|
||||
{
|
||||
StopAuxStream();
|
||||
if (_screenStreamId != 0) StopScreenAudio();
|
||||
_client.SetPushToTalk(false);
|
||||
_client.StopStream(_micStreamId);
|
||||
_micStreamId = 0;
|
||||
pbLevel.Value = 0;
|
||||
SetVoiceJoinedState(false);
|
||||
AddActivity("Left voice");
|
||||
_client.LeaveVoice();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,9 +694,42 @@ public partial class MainForm : Form
|
||||
_miJoinVoice.Text = joined ? "Leave &Voice" : "&Join Voice";
|
||||
chkMute.Enabled = joined;
|
||||
chkDeafen.Enabled = joined;
|
||||
radioVad.Enabled = joined;
|
||||
radioPtt.Enabled = joined;
|
||||
radioAlwaysOn.Enabled = joined;
|
||||
}
|
||||
|
||||
private void HandleVoiceState(VoiceCatEvent ev)
|
||||
{
|
||||
bool subscribed = ev.U32a != 0;
|
||||
if (subscribed)
|
||||
{
|
||||
var (result, streamId) = _client.StartStream(VcStreamKind.Mic, "Microphone");
|
||||
if (result == VcResult.Ok)
|
||||
{
|
||||
_micStreamId = streamId;
|
||||
var mode = (VcInputMode)_voiceSettings.InputMode;
|
||||
if (_voiceSettings.InputDeviceId is string devId)
|
||||
_client.SetInputDevice(streamId, devId);
|
||||
_client.SetCaptureChannels(streamId, _voiceSettings.StereoMic ? 2u : 1u);
|
||||
_client.SetInputMode(mode);
|
||||
if (mode == VcInputMode.VoiceActivation)
|
||||
_client.SetVadThreshold(VadThresholdFromSettings());
|
||||
_client.SetInputGain(_voiceSettings.MicGain / 100f);
|
||||
_client.SetInputNoiseReduction(_voiceSettings.MicNoiseReduction);
|
||||
SetVoiceJoinedState(true);
|
||||
AddActivity("Joined voice — microphone active");
|
||||
_feedback.PlaySound(SoundEvent.VoiceOn);
|
||||
StartAuxStream();
|
||||
}
|
||||
else
|
||||
{
|
||||
AddActivity($"Failed to start microphone: {result}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
SetVoiceJoinedState(false);
|
||||
AddActivity("Left voice");
|
||||
_feedback.PlaySound(SoundEvent.VoiceOff);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySelfMute() =>
|
||||
@@ -622,111 +739,172 @@ public partial class MainForm : Form
|
||||
{
|
||||
if (_screenStreamId == 0)
|
||||
{
|
||||
var (result, streamId) = _client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
|
||||
if (result == VcResult.Ok)
|
||||
{
|
||||
_screenStreamId = streamId;
|
||||
tsbScreenShare.Text = "Stop Screen Audio";
|
||||
_miScreenShare.Text = "Stop Screen &Audio";
|
||||
AddActivity("Started sharing screen audio");
|
||||
StartScreenAudio();
|
||||
}
|
||||
else
|
||||
{
|
||||
StopScreenAudio();
|
||||
}
|
||||
}
|
||||
|
||||
private void StartScreenAudio()
|
||||
{
|
||||
using var picker = new AppAudioPickerDialog();
|
||||
if (picker.ShowDialog(this) != DialogResult.OK || picker.ChosenScope == null) return;
|
||||
|
||||
var scope = picker.ChosenScope;
|
||||
|
||||
if (scope is EntireDesktop { ExcludeSelf: false })
|
||||
{
|
||||
// Existing whole-device WASAPI loopback path — core handles it.
|
||||
var (result, streamId) = _client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
|
||||
if (result != VcResult.Ok)
|
||||
{
|
||||
AddActivity($"Failed to start screen audio: {result}");
|
||||
return;
|
||||
}
|
||||
_screenStreamId = streamId;
|
||||
AddActivity("Sharing screen audio: entire desktop");
|
||||
}
|
||||
else
|
||||
{
|
||||
// External-feed path: suppress core loopback, C# mixer feeds PCM. Covers the
|
||||
// per-app modes and "entire desktop except VoiceCat" (single EXCLUDE of self).
|
||||
var (result, streamId) = _client.StartStreamExternalFeed(VcStreamKind.ScreenAudio, "App audio");
|
||||
if (result != VcResult.Ok)
|
||||
{
|
||||
AddActivity($"Failed to start screen audio: {result}");
|
||||
return;
|
||||
}
|
||||
_screenStreamId = streamId;
|
||||
|
||||
_screenMixer = new ProcessAudioMixer();
|
||||
_screenMixer.Start(scope, _client, streamId);
|
||||
|
||||
string desc = scope switch
|
||||
{
|
||||
EntireDesktop => "entire desktop (excluding VoiceCat)",
|
||||
OnlyApps o => $"only {o.Pids.Count} app(s)",
|
||||
AllExceptApps a => $"all except {a.Names[0]}",
|
||||
_ => "apps",
|
||||
};
|
||||
AddActivity($"Sharing screen audio: {desc}");
|
||||
}
|
||||
|
||||
tsbScreenShare.Text = "Stop Screen Audio";
|
||||
_miScreenShare.Text = "Stop Screen &Audio";
|
||||
}
|
||||
|
||||
private void StopScreenAudio()
|
||||
{
|
||||
_screenMixer?.Stop();
|
||||
_screenMixer?.Dispose();
|
||||
_screenMixer = null;
|
||||
|
||||
_client.StopStream(_screenStreamId);
|
||||
_screenStreamId = 0;
|
||||
tsbScreenShare.Text = "Share Screen Audio";
|
||||
_miScreenShare.Text = "Share Screen &Audio";
|
||||
AddActivity("Stopped sharing screen audio");
|
||||
}
|
||||
|
||||
// ── Aux input stream (second hardware input device) ─────────────────────────
|
||||
// A second outgoing stream (kind = AUX_DEVICE, external_feed). The core can't open a second
|
||||
// capture device, so we capture the chosen device here and feed PCM in — the same external-
|
||||
// feed pipeline as per-app screen audio. Tied to the voice session: started on Join Voice
|
||||
// (when enabled) and stopped on Leave Voice. The aux is always-on (the core never gates
|
||||
// AUX_DEVICE on VAD/PTT); volume is applied client-side before feeding.
|
||||
|
||||
private void StartAuxStream()
|
||||
{
|
||||
if (_auxStreamId != 0 || !_voiceSettings.AuxEnabled) return;
|
||||
|
||||
var (result, streamId) = _client.StartStreamExternalFeed(VcStreamKind.AuxDevice, "Aux device");
|
||||
if (result != VcResult.Ok)
|
||||
{
|
||||
AddActivity($"Failed to start aux stream: {result}");
|
||||
return;
|
||||
}
|
||||
_auxStreamId = streamId;
|
||||
|
||||
_auxCapture = new InputDeviceCapture(_voiceSettings.AuxDeviceId);
|
||||
_auxCapture.PcmFrameReady += OnAuxPcmFrame;
|
||||
if (!_auxCapture.Start())
|
||||
{
|
||||
AddActivity("Failed to open aux input device");
|
||||
StopAuxStream();
|
||||
return;
|
||||
}
|
||||
AddActivity("Aux input stream active");
|
||||
}
|
||||
|
||||
private void StopAuxStream()
|
||||
{
|
||||
DisposeAuxCapture();
|
||||
if (_auxStreamId != 0)
|
||||
{
|
||||
_client.StopStream(_auxStreamId);
|
||||
_auxStreamId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-open the capture on a different device while the aux stream stays up (the core stream id
|
||||
// is unchanged — only the client-side capture source changes).
|
||||
private void RestartAuxCapture()
|
||||
{
|
||||
if (_auxStreamId == 0) return;
|
||||
DisposeAuxCapture();
|
||||
_auxCapture = new InputDeviceCapture(_voiceSettings.AuxDeviceId);
|
||||
_auxCapture.PcmFrameReady += OnAuxPcmFrame;
|
||||
if (!_auxCapture.Start())
|
||||
AddActivity("Failed to open aux input device");
|
||||
}
|
||||
|
||||
private void DisposeAuxCapture()
|
||||
{
|
||||
if (_auxCapture == null) return;
|
||||
_auxCapture.PcmFrameReady -= OnAuxPcmFrame;
|
||||
_auxCapture.Stop();
|
||||
_auxCapture.Dispose();
|
||||
_auxCapture = null;
|
||||
}
|
||||
|
||||
// Fired on the capture thread. vc_stream_feed_pcm is thread-safe, so feed directly. Gain is
|
||||
// read live from settings each frame (so the volume slider takes effect immediately).
|
||||
private void OnAuxPcmFrame(short[] pcm, int samplesPerChannel, int channels)
|
||||
{
|
||||
if (_auxStreamId == 0) return;
|
||||
float gain = _voiceSettings.AuxGain / 100f;
|
||||
if (gain != 1f)
|
||||
{
|
||||
for (int i = 0; i < pcm.Length; i++)
|
||||
pcm[i] = (short)Math.Clamp((int)MathF.Round(pcm[i] * gain),
|
||||
short.MinValue, short.MaxValue);
|
||||
}
|
||||
_client.StreamFeedPcm(_auxStreamId, pcm, samplesPerChannel, (uint)channels);
|
||||
}
|
||||
|
||||
private void TrkOutputVolume_Scroll(object? sender, EventArgs e) =>
|
||||
_client.SetOutputVolume(trkOutputVolume.Value / 100f);
|
||||
|
||||
private void RadioVad_CheckedChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (!radioVad.Checked) return;
|
||||
lblPttKey.Visible = false;
|
||||
btnChangePtt.Visible = false;
|
||||
lblVadThreshold.Visible = true;
|
||||
trkVadThreshold.Visible = true;
|
||||
if (_micStreamId != 0)
|
||||
{
|
||||
_client.SetInputMode(VcInputMode.VoiceActivation);
|
||||
_client.SetVadThreshold(VadThresholdFromSlider());
|
||||
}
|
||||
}
|
||||
|
||||
private void RadioPtt_CheckedChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (!radioPtt.Checked) return;
|
||||
lblPttKey.Text = $"({_pttKey})";
|
||||
lblPttKey.Visible = true;
|
||||
btnChangePtt.Visible = true;
|
||||
lblVadThreshold.Visible = false;
|
||||
trkVadThreshold.Visible = false;
|
||||
if (_micStreamId != 0)
|
||||
{
|
||||
_client.SetInputMode(VcInputMode.PushToTalk);
|
||||
_client.SetPushToTalk(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void RadioAlwaysOn_CheckedChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (!radioAlwaysOn.Checked) return;
|
||||
lblPttKey.Visible = false;
|
||||
btnChangePtt.Visible = false;
|
||||
lblVadThreshold.Visible = false;
|
||||
trkVadThreshold.Visible = false;
|
||||
if (_micStreamId != 0) _client.SetInputMode(VcInputMode.AlwaysOn);
|
||||
}
|
||||
|
||||
private void TrkVadThreshold_Scroll(object? sender, EventArgs e)
|
||||
{
|
||||
if (_micStreamId != 0 && radioVad.Checked)
|
||||
_client.SetVadThreshold(VadThresholdFromSlider());
|
||||
}
|
||||
|
||||
private float VadThresholdFromSlider() =>
|
||||
0.1f * (1f - (trkVadThreshold.Value - 1f) / 99f);
|
||||
|
||||
private VcInputMode CurrentInputMode() =>
|
||||
radioPtt.Checked ? VcInputMode.PushToTalk :
|
||||
radioAlwaysOn.Checked ? VcInputMode.AlwaysOn :
|
||||
VcInputMode.VoiceActivation;
|
||||
|
||||
private void BtnChangePtt_Click(object? sender, EventArgs e)
|
||||
{
|
||||
using var dlg = new PttKeyCaptureDialog(_pttKey);
|
||||
if (dlg.ShowDialog(this) == DialogResult.OK)
|
||||
{
|
||||
_pttKey = dlg.CapturedKey;
|
||||
lblPttKey.Text = $"({_pttKey})";
|
||||
}
|
||||
}
|
||||
|
||||
private void CboInputDevice_SelectedIndexChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_micStreamId == 0) return;
|
||||
string? deviceId = (cboInputDevice.SelectedItem as DeviceInfo)?.Id;
|
||||
_client.SetInputDevice(_micStreamId, deviceId);
|
||||
}
|
||||
private float VadThresholdFromSettings() =>
|
||||
0.1f * (1f - (_voiceSettings.VadThresholdSlider - 1f) / 99f);
|
||||
|
||||
// ── PTT key handling (focus-scoped) ───────────────────────────────────────
|
||||
|
||||
private void MainForm_KeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (!radioPtt.Checked || e.KeyCode != _pttKey || _micStreamId == 0) return;
|
||||
if (_voiceSettings.SystemWidePtt) return; // handled by the WM_INPUT / Raw Input path
|
||||
if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk) return;
|
||||
if (e.KeyCode != _pttKey || _micStreamId == 0) return;
|
||||
if (ActiveControl is TextBox or RichTextBox) return;
|
||||
_client.SetPushToTalk(true);
|
||||
lblPttKey.Text = $"({_pttKey} ▶)";
|
||||
e.Handled = true;
|
||||
if (!_pttEngaged) // first key-down only, not auto-repeat
|
||||
{
|
||||
_pttEngaged = true;
|
||||
_feedback.PlaySound(SoundEvent.Ptt);
|
||||
}
|
||||
e.Handled = e.SuppressKeyPress = true;
|
||||
}
|
||||
|
||||
private void MainForm_HotkeyDown(object? sender, KeyEventArgs e)
|
||||
@@ -737,31 +915,120 @@ public partial class MainForm : Form
|
||||
{
|
||||
case Keys.V:
|
||||
BtnMicToggle_Click(null, EventArgs.Empty);
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Keys.S:
|
||||
BtnScreenShareToggle_Click(null, EventArgs.Empty);
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Keys.M:
|
||||
chkMute.Checked = !chkMute.Checked;
|
||||
ApplySelfMute();
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Keys.D:
|
||||
chkDeafen.Checked = !chkDeafen.Checked;
|
||||
ApplySelfMute();
|
||||
e.Handled = true;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
// A handled hotkey: suppress the follow-on WM_CHAR so the focused control
|
||||
// (channel tree / user list) doesn't emit the system "ding".
|
||||
e.Handled = e.SuppressKeyPress = true;
|
||||
}
|
||||
|
||||
private void MainForm_KeyUp(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (!radioPtt.Checked || e.KeyCode != _pttKey || _micStreamId == 0) return;
|
||||
if (_voiceSettings.SystemWidePtt) return; // handled by the WM_INPUT / Raw Input path
|
||||
if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk) return;
|
||||
if (e.KeyCode != _pttKey || _micStreamId == 0) return;
|
||||
_client.SetPushToTalk(false);
|
||||
lblPttKey.Text = $"({_pttKey})";
|
||||
e.Handled = true;
|
||||
_pttEngaged = false;
|
||||
e.Handled = e.SuppressKeyPress = true;
|
||||
}
|
||||
|
||||
// ── System-wide PTT (Raw Input / WM_INPUT) ────────────────────────────────
|
||||
// When the user enables "system-wide" PTT we observe the PTT key via the Raw Input API so it
|
||||
// works while another app is focused. See VoiceCat.App.Native.RawInput for why this is used in
|
||||
// preference to a low-level keyboard hook (antivirus keylogger heuristics).
|
||||
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
base.OnHandleCreated(e);
|
||||
ApplySystemWidePtt();
|
||||
}
|
||||
|
||||
protected override void OnHandleDestroyed(EventArgs e)
|
||||
{
|
||||
if (_rawInputRegistered)
|
||||
{
|
||||
RawInput.UnregisterKeyboardSink();
|
||||
_rawInputRegistered = false;
|
||||
}
|
||||
base.OnHandleDestroyed(e);
|
||||
}
|
||||
|
||||
protected override void WndProc(ref Message m)
|
||||
{
|
||||
if (m.Msg == RawInput.WM_INPUT && _voiceSettings.SystemWidePtt)
|
||||
HandleRawInput(m.LParam);
|
||||
base.WndProc(ref m);
|
||||
}
|
||||
|
||||
/// <summary>Register or tear down the background keyboard sink to match the current
|
||||
/// <see cref="VoiceSettings.SystemWidePtt"/> setting. Safe to call repeatedly.</summary>
|
||||
private void ApplySystemWidePtt()
|
||||
{
|
||||
if (!IsHandleCreated) return; // OnHandleCreated will (re)apply once the handle exists
|
||||
bool want = _voiceSettings.SystemWidePtt;
|
||||
if (want && !_rawInputRegistered)
|
||||
{
|
||||
_rawInputRegistered = RawInput.RegisterKeyboardSink(Handle);
|
||||
}
|
||||
else if (!want && _rawInputRegistered)
|
||||
{
|
||||
RawInput.UnregisterKeyboardSink();
|
||||
_rawInputRegistered = false;
|
||||
// Release any PTT that was held via Raw Input so it can't stick after switching modes.
|
||||
if (_micStreamId != 0) _client.SetPushToTalk(false);
|
||||
_pttEngaged = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRawInput(IntPtr lParam)
|
||||
{
|
||||
if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk || _micStreamId == 0)
|
||||
return;
|
||||
if (!RawInput.TryParseKey(lParam, out ushort vkey, out bool keyUp)) return;
|
||||
if (vkey != (ushort)_pttKey) return;
|
||||
|
||||
if (keyUp)
|
||||
{
|
||||
_client.SetPushToTalk(false);
|
||||
_pttEngaged = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Key-down. Don't transmit while typing into our OWN text fields — matches the
|
||||
// focus-scoped guard. When VoiceCat is in the background ContainsFocus is false, so the
|
||||
// key still transmits (the whole point of system-wide PTT).
|
||||
if (ContainsFocus && ActiveControl is TextBox or RichTextBox) return;
|
||||
_client.SetPushToTalk(true);
|
||||
if (!_pttEngaged) // first key-down only, not auto-repeat
|
||||
{
|
||||
_pttEngaged = true;
|
||||
_feedback.PlaySound(SoundEvent.Ptt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Watchdog (driven by the pump timer) that releases system-wide PTT if the key-up
|
||||
/// was never observed — e.g. across an RDP or lock-screen focus switch — so PTT can't stick.</summary>
|
||||
private void PttWatchdog()
|
||||
{
|
||||
if (!_pttEngaged || !_voiceSettings.SystemWidePtt || _micStreamId == 0) return;
|
||||
if (!RawInput.IsKeyDown((int)_pttKey))
|
||||
{
|
||||
_client.SetPushToTalk(false);
|
||||
_pttEngaged = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel navigation ────────────────────────────────────────────────────
|
||||
@@ -811,13 +1078,17 @@ public partial class MainForm : Form
|
||||
win = new PrivateMessageForm(_client, userId, nick, _selfUserId);
|
||||
win.FormClosed += (_, _) => _pmWindows.Remove(userId);
|
||||
_pmWindows[userId] = win;
|
||||
win.Show(this);
|
||||
// Show without an owner: an owned form is forced to stay above MainForm and pulls
|
||||
// focus back to itself, so the main window can't be worked in while a PM is open.
|
||||
// OnFormClosed already closes any open PM windows, so this doesn't leak.
|
||||
win.Show();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (win.WindowState == FormWindowState.Minimized)
|
||||
win.WindowState = FormWindowState.Normal;
|
||||
win.BringToFront();
|
||||
// Raise it for this explicit user-initiated open without the owner-style focus trap.
|
||||
win.Activate();
|
||||
}
|
||||
return win;
|
||||
}
|
||||
@@ -843,7 +1114,7 @@ public partial class MainForm : Form
|
||||
OpenPmWindow(dlg.SelectedUserId);
|
||||
}
|
||||
|
||||
// ── M5: Moderation helpers ────────────────────────────────────────────────
|
||||
// ── Moderation helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private void UpdateSelfServerMuteState(bool muted, bool deafened)
|
||||
{
|
||||
@@ -875,8 +1146,8 @@ public partial class MainForm : Form
|
||||
|
||||
var editInfo = new ChannelEditInfo(
|
||||
channel.Id, channel.ParentId, channel.Name, channel.Topic,
|
||||
channel.PasswordProtected, null, channel.MaxUsers, 0,
|
||||
new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10));
|
||||
channel.PasswordProtected, null, channel.MaxUsers, channel.SortOrder,
|
||||
channel.Audio);
|
||||
|
||||
using var dlg = new ChannelEditDialog(_channels, editInfo);
|
||||
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
|
||||
@@ -1019,10 +1290,12 @@ public partial class MainForm : Form
|
||||
_client.EventReceived -= OnEvent;
|
||||
foreach (var win in _pmWindows.Values.ToList()) win.Close();
|
||||
_pmWindows.Clear();
|
||||
if (_screenStreamId != 0) _client.StopStream(_screenStreamId);
|
||||
if (_screenStreamId != 0) { _screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null; _client.StopStream(_screenStreamId); }
|
||||
if (_auxStreamId != 0) StopAuxStream();
|
||||
if (_micStreamId != 0) _client.StopStream(_micStreamId);
|
||||
_client.Disconnect();
|
||||
_client.Dispose();
|
||||
_feedback.Dispose();
|
||||
base.OnFormClosed(e);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using VoiceCat.App.Notifications;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Edits and persists <see cref="FeedbackSettings"/> (event sounds + spoken announcements).
|
||||
/// On OK the supplied <see cref="EventFeedback"/> is updated live and the settings saved.
|
||||
/// </summary>
|
||||
public sealed class NotificationSettingsForm : Form
|
||||
{
|
||||
private readonly EventFeedback _feedback;
|
||||
|
||||
private readonly CheckBox _chkSounds;
|
||||
private readonly CheckBox _chkSpeech;
|
||||
private readonly TrackBar _trkVolume;
|
||||
private readonly CheckBox _chkSelfTalk;
|
||||
private readonly CheckBox _chkPtt;
|
||||
|
||||
public NotificationSettingsForm(EventFeedback feedback)
|
||||
{
|
||||
_feedback = feedback;
|
||||
var s = feedback.Settings;
|
||||
|
||||
Text = "Notification settings";
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(360, 300);
|
||||
|
||||
_chkSounds = new CheckBox
|
||||
{
|
||||
Text = "Play event &sounds",
|
||||
Location = new Point(12, 12),
|
||||
AutoSize = true,
|
||||
Checked = s.Sounds,
|
||||
};
|
||||
|
||||
var lblVolume = new Label
|
||||
{
|
||||
Text = "Sound &volume:",
|
||||
Location = new Point(12, 42),
|
||||
AutoSize = true,
|
||||
};
|
||||
_trkVolume = new TrackBar
|
||||
{
|
||||
Location = new Point(12, 64),
|
||||
Size = new Size(330, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 100,
|
||||
TickFrequency = 10,
|
||||
Value = (int)Math.Round(Math.Clamp(s.Volume, 0f, 1f) * 100),
|
||||
};
|
||||
_trkVolume.AccessibleName = "Sound volume";
|
||||
|
||||
_chkSpeech = new CheckBox
|
||||
{
|
||||
Text = "&Speak events (text-to-speech)",
|
||||
Location = new Point(12, 118),
|
||||
AutoSize = true,
|
||||
Checked = s.Speech,
|
||||
};
|
||||
var lblSpeechHint = new Label
|
||||
{
|
||||
Text = feedback.SpeechAvailable
|
||||
? "Announces joins/leaves and reads message text aloud."
|
||||
: "No speech engine available on this machine.",
|
||||
Location = new Point(30, 142),
|
||||
AutoSize = true,
|
||||
ForeColor = SystemColors.GrayText,
|
||||
};
|
||||
|
||||
var lblOptional = new Label
|
||||
{
|
||||
Text = "Optional sounds:",
|
||||
Location = new Point(12, 174),
|
||||
AutoSize = true,
|
||||
};
|
||||
_chkSelfTalk = new CheckBox
|
||||
{
|
||||
Text = "Your own voice-&activity start/stop",
|
||||
Location = new Point(12, 198),
|
||||
AutoSize = true,
|
||||
Checked = s.SelfTalkSounds,
|
||||
};
|
||||
_chkPtt = new CheckBox
|
||||
{
|
||||
Text = "&Push-to-talk cue",
|
||||
Location = new Point(12, 224),
|
||||
AutoSize = true,
|
||||
Checked = s.PttSound,
|
||||
};
|
||||
|
||||
var btnOk = new Button
|
||||
{
|
||||
Text = "&OK",
|
||||
DialogResult = DialogResult.OK,
|
||||
Location = new Point(192, 262),
|
||||
Size = new Size(75, 27),
|
||||
};
|
||||
var btnCancel = new Button
|
||||
{
|
||||
Text = "&Cancel",
|
||||
DialogResult = DialogResult.Cancel,
|
||||
Location = new Point(273, 262),
|
||||
Size = new Size(75, 27),
|
||||
};
|
||||
|
||||
AcceptButton = btnOk;
|
||||
CancelButton = btnCancel;
|
||||
Controls.AddRange([_chkSounds, lblVolume, _trkVolume, _chkSpeech, lblSpeechHint,
|
||||
lblOptional, _chkSelfTalk, _chkPtt, btnOk, btnCancel]);
|
||||
|
||||
btnOk.Click += (_, _) => Apply();
|
||||
}
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
var s = _feedback.Settings;
|
||||
s.Sounds = _chkSounds.Checked;
|
||||
s.Speech = _chkSpeech.Checked;
|
||||
s.Volume = _trkVolume.Value / 100f;
|
||||
s.SelfTalkSounds = _chkSelfTalk.Checked;
|
||||
s.PttSound = _chkPtt.Checked;
|
||||
s.Save();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
/// <summary>Small modal for "type a password right now" — used when a saved server's
|
||||
/// password wasn't remembered, and (Phase E) for password-protected channel joins.</summary>
|
||||
/// password wasn't remembered, and for password-protected channel joins.</summary>
|
||||
public partial class PasswordPromptDialog : Form
|
||||
{
|
||||
public string Password => txtPassword.Text;
|
||||
|
||||
@@ -117,11 +117,11 @@ public sealed class PerUserTuningDialog : Form
|
||||
var trkGain = new TrackBar
|
||||
{
|
||||
AccessibleName = $"Gain for {s.Label}",
|
||||
AccessibleDescription = "Volume level for this stream. 100 is normal (1.0×), 200 is double.",
|
||||
AccessibleDescription = "Volume level for this stream. 100 is normal (1.0×); range 0–400 percent.",
|
||||
Location = new Point(12, y + 20),
|
||||
Size = new Size(260, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 200,
|
||||
Maximum = 400,
|
||||
Value = ClampToTrack(gain0),
|
||||
TickFrequency = 25,
|
||||
SmallChange = 5,
|
||||
@@ -150,7 +150,7 @@ public sealed class PerUserTuningDialog : Form
|
||||
|
||||
var chkNr = new CheckBox
|
||||
{
|
||||
Text = "&Noise reduction (planned — currently passthrough)",
|
||||
Text = "&Noise reduction",
|
||||
AutoSize = true,
|
||||
Location = new Point(96, y + 48),
|
||||
Checked = nr0,
|
||||
@@ -186,7 +186,7 @@ public sealed class PerUserTuningDialog : Form
|
||||
private static int ClampToTrack(float gain)
|
||||
{
|
||||
int v = (int)Math.Round(gain * 100f);
|
||||
return Math.Max(0, Math.Min(200, v));
|
||||
return Math.Max(0, Math.Min(400, v));
|
||||
}
|
||||
|
||||
private sealed class StreamRow
|
||||
|
||||
@@ -26,6 +26,11 @@ public sealed class PrivateMessageForm : Form
|
||||
ClientSize = new Size(480, 360);
|
||||
MinimumSize = new Size(320, 240);
|
||||
StartPosition = FormStartPosition.Manual;
|
||||
KeyPreview = true;
|
||||
KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.KeyCode == Keys.Escape) { e.Handled = e.SuppressKeyPress = true; Close(); }
|
||||
};
|
||||
|
||||
_rtbHistory = new RichTextBox
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ using VoiceCat.Interop;
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// TOFU server-identity confirmation (M4). Shown only for VcTofuStatus.FirstConnect/Mismatch
|
||||
/// TOFU server-identity confirmation. Shown only for VcTofuStatus.FirstConnect/Mismatch
|
||||
/// — never Matched (that's the silent-success "subsequent connects verify the pin" path
|
||||
/// docs/security.md describes; showing a dialog on every routine reconnect would be exactly
|
||||
/// the "overly chatty" experience this project avoids elsewhere too).
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VoiceCat.App.Models;
|
||||
|
||||
/// <summary>
|
||||
/// User's send-side voice input preferences — transmission mode, VAD sensitivity, mic input
|
||||
/// gain, and the push-to-talk key. Persisted to %AppData%\VoiceCat\voice.json, same pattern as
|
||||
/// <see cref="ServerListStore"/> and FeedbackSettings: a missing or corrupt file yields defaults
|
||||
/// rather than throwing. Slider-position values are stored as-is so MainForm can restore the
|
||||
/// TrackBars directly.
|
||||
/// </summary>
|
||||
public sealed class VoiceSettings
|
||||
{
|
||||
/// <summary>Transmission mode: 0 = voice activation, 1 = push-to-talk, 2 = always on
|
||||
/// (matches Interop's VcInputMode).</summary>
|
||||
public int InputMode { get; set; } = 0;
|
||||
|
||||
/// <summary>VAD sensitivity slider position, 1–100 (default mirrors the designer's 76).</summary>
|
||||
public int VadThresholdSlider { get; set; } = 76;
|
||||
|
||||
/// <summary>Microphone input gain slider position, 0–400 percent (100 = unity).</summary>
|
||||
public int MicGain { get; set; } = 100;
|
||||
|
||||
/// <summary>Send-side mic noise reduction (RNNoise) toggle. MIC stream only, mono only;
|
||||
/// denoises captured mic PCM before input gain and VAD/PTT gate so everyone hears the
|
||||
/// cleaned signal. Independent of the per-listener receive-side NR.</summary>
|
||||
public bool MicNoiseReduction { get; set; } = false;
|
||||
|
||||
/// <summary>Capture the mic in stereo (interleaved L/R) instead of mono. Off by default. Real
|
||||
/// stereo only reaches the wire on a stereo channel; on a mono channel the core folds the mic
|
||||
/// to mono. Applied when the capture device next starts (Join Voice or an audio restart).</summary>
|
||||
public bool StereoMic { get; set; } = false;
|
||||
|
||||
/// <summary>Push-to-talk key, stored as the integer value of System.Windows.Forms.Keys.</summary>
|
||||
public int PttKey { get; set; } = (int)Keys.F8;
|
||||
|
||||
/// <summary>Make push-to-talk work system-wide — i.e. while another app is focused. When on,
|
||||
/// MainForm registers a background Raw Input keyboard device (WM_INPUT + RIDEV_INPUTSINK) so the
|
||||
/// PTT key is observed even when VoiceCat is not in the foreground; when off, PTT is focus-scoped
|
||||
/// (only fires while the VoiceCat window has focus). Raw Input is used instead of a low-level
|
||||
/// keyboard hook to avoid antivirus keylogger heuristics.</summary>
|
||||
public bool SystemWidePtt { get; set; } = true;
|
||||
|
||||
/// <summary>Saved device ID from the last session; null means use the system default.</summary>
|
||||
public string? InputDeviceId { get; set; } = null;
|
||||
|
||||
/// <summary>Whether the secondary "aux" outgoing stream is enabled. The aux stream is a
|
||||
/// second hardware input device the client captures itself and feeds to the core via
|
||||
/// vc_stream_feed_pcm (kind = AUX_DEVICE, external_feed). Lets a user transmit e.g. mic +
|
||||
/// a line-in at once.</summary>
|
||||
public bool AuxEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>WASAPI endpoint id of the aux capture device; null = system default capture
|
||||
/// device. NOTE: this is a WASAPI device id (from <see cref="Audio.InputDeviceEnumerator"/>),
|
||||
/// NOT a core/miniaudio id — the aux device is opened client-side, so the two id spaces differ.</summary>
|
||||
public string? AuxDeviceId { get; set; } = null;
|
||||
|
||||
/// <summary>Aux input volume slider position, 0–400 percent (100 = unity). Applied client-side
|
||||
/// to the captured PCM before feeding (the core's input gain is mic-only and global).</summary>
|
||||
public int AuxGain { get; set; } = 100;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
private static string AppDataDir => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VoiceCat");
|
||||
|
||||
private static string FilePath => Path.Combine(AppDataDir, "voice.json");
|
||||
|
||||
public static VoiceSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(FilePath)) return new VoiceSettings();
|
||||
string json = File.ReadAllText(FilePath);
|
||||
return JsonSerializer.Deserialize<VoiceSettings>(json) ?? new VoiceSettings();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new VoiceSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
Directory.CreateDirectory(AppDataDir);
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace VoiceCat.App.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrapper over the Win32 Raw Input API (user32) used to observe the push-to-talk key
|
||||
/// system-wide — i.e. while another application is focused.
|
||||
///
|
||||
/// We deliberately use Raw Input (<c>RegisterRawInputDevices</c> + <c>WM_INPUT</c> with
|
||||
/// <c>RIDEV_INPUTSINK</c>) rather than a low-level keyboard hook (<c>SetWindowsHookEx</c> /
|
||||
/// <c>WH_KEYBOARD_LL</c>). A low-level hook is the textbook keylogger pattern and is exactly
|
||||
/// what antivirus heuristics flag — especially for an unsigned, statically linked MinGW binary
|
||||
/// like ours. Raw Input involves no DLL injection and no global hook: the OS simply posts
|
||||
/// <c>WM_INPUT</c> messages to our own window's message queue (it is the same input path games
|
||||
/// use), so it does not trip those heuristics. It also passes keystrokes through to the focused
|
||||
/// app rather than swallowing them.
|
||||
///
|
||||
/// Only the keyboard device is registered; <see cref="TryParseKey"/> filters to the single PTT
|
||||
/// virtual-key code one level up (MainForm).
|
||||
/// </summary>
|
||||
internal static partial class RawInput
|
||||
{
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────────────
|
||||
public const int WM_INPUT = 0x00FF;
|
||||
|
||||
private const uint RID_INPUT = 0x10000003; // GetRawInputData: get the raw data
|
||||
private const uint RIM_TYPEKEYBOARD = 1; // RAWINPUTHEADER.dwType for a keyboard
|
||||
private const uint RIDEV_INPUTSINK = 0x00000100; // receive input even when not in foreground
|
||||
private const uint RIDEV_REMOVE = 0x00000001; // stop receiving input from the device
|
||||
private const ushort RI_KEY_BREAK = 0x01; // RAWKEYBOARD.Flags bit set on key-up
|
||||
|
||||
private const ushort HID_USAGE_PAGE_GENERIC = 0x01;
|
||||
private const ushort HID_USAGE_GENERIC_KEYBOARD = 0x06;
|
||||
|
||||
// ── Structs (must match the Win32 layout exactly) ──────────────────────────────────────
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RAWINPUTDEVICE
|
||||
{
|
||||
public ushort usUsagePage;
|
||||
public ushort usUsage;
|
||||
public uint dwFlags;
|
||||
public IntPtr hwndTarget;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RAWINPUTHEADER
|
||||
{
|
||||
public uint dwType;
|
||||
public uint dwSize;
|
||||
public IntPtr hDevice;
|
||||
public IntPtr wParam;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RAWKEYBOARD
|
||||
{
|
||||
public ushort MakeCode;
|
||||
public ushort Flags;
|
||||
public ushort Reserved;
|
||||
public ushort VKey;
|
||||
public uint Message;
|
||||
public uint ExtraInformation;
|
||||
}
|
||||
|
||||
// ── P/Invoke (source-generated via LibraryImport, matching VoiceCat.Interop) ────────────
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool RegisterRawInputDevices(
|
||||
ref RAWINPUTDEVICE pRawInputDevices, uint uiNumDevices, uint cbSize);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
private static partial uint GetRawInputData(
|
||||
IntPtr hRawInput, uint uiCommand, IntPtr pData, ref uint pcbSize, uint cbSizeHeader);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
private static partial short GetAsyncKeyState(int vKey);
|
||||
|
||||
// ── Public helpers ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Register a background keyboard sink targeting <paramref name="hwnd"/>. With
|
||||
/// RIDEV_INPUTSINK the window receives WM_INPUT even when it is not in the foreground.</summary>
|
||||
public static bool RegisterKeyboardSink(IntPtr hwnd)
|
||||
{
|
||||
var rid = new RAWINPUTDEVICE
|
||||
{
|
||||
usUsagePage = HID_USAGE_PAGE_GENERIC,
|
||||
usUsage = HID_USAGE_GENERIC_KEYBOARD,
|
||||
dwFlags = RIDEV_INPUTSINK,
|
||||
hwndTarget = hwnd,
|
||||
};
|
||||
return RegisterRawInputDevices(ref rid, 1, (uint)Marshal.SizeOf<RAWINPUTDEVICE>());
|
||||
}
|
||||
|
||||
/// <summary>Tear down the keyboard sink. For RIDEV_REMOVE the target handle must be NULL.</summary>
|
||||
public static bool UnregisterKeyboardSink()
|
||||
{
|
||||
var rid = new RAWINPUTDEVICE
|
||||
{
|
||||
usUsagePage = HID_USAGE_PAGE_GENERIC,
|
||||
usUsage = HID_USAGE_GENERIC_KEYBOARD,
|
||||
dwFlags = RIDEV_REMOVE,
|
||||
hwndTarget = IntPtr.Zero,
|
||||
};
|
||||
return RegisterRawInputDevices(ref rid, 1, (uint)Marshal.SizeOf<RAWINPUTDEVICE>());
|
||||
}
|
||||
|
||||
/// <summary>Decode a WM_INPUT message's <paramref name="hRawInput"/> (the message's LParam).
|
||||
/// Returns false for anything that isn't a keyboard event. On success, <paramref name="vkey"/>
|
||||
/// is the virtual-key code and <paramref name="keyUp"/> distinguishes a release from a press
|
||||
/// (auto-repeat arrives as repeated presses).</summary>
|
||||
public static bool TryParseKey(IntPtr hRawInput, out ushort vkey, out bool keyUp)
|
||||
{
|
||||
vkey = 0;
|
||||
keyUp = false;
|
||||
|
||||
uint headerSize = (uint)Marshal.SizeOf<RAWINPUTHEADER>();
|
||||
uint size = 0;
|
||||
// First call (pData == NULL) returns 0 on success and fills `size` with the buffer length.
|
||||
if (GetRawInputData(hRawInput, RID_INPUT, IntPtr.Zero, ref size, headerSize) != 0 || size == 0)
|
||||
return false;
|
||||
|
||||
IntPtr buf = Marshal.AllocHGlobal((int)size);
|
||||
try
|
||||
{
|
||||
if (GetRawInputData(hRawInput, RID_INPUT, buf, ref size, headerSize) != size)
|
||||
return false;
|
||||
|
||||
var header = Marshal.PtrToStructure<RAWINPUTHEADER>(buf);
|
||||
if (header.dwType != RIM_TYPEKEYBOARD)
|
||||
return false;
|
||||
|
||||
// The keyboard payload immediately follows the (8-byte-aligned) header.
|
||||
var kb = Marshal.PtrToStructure<RAWKEYBOARD>(buf + (int)headerSize);
|
||||
vkey = kb.VKey;
|
||||
keyUp = (kb.Flags & RI_KEY_BREAK) != 0;
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(buf);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True if the given virtual-key code is currently held down. Used as a watchdog to
|
||||
/// catch a missed key-up (e.g. across an RDP / lock-screen focus switch) so PTT can't stick.</summary>
|
||||
public static bool IsKeyDown(int vKey) => (GetAsyncKeyState(vKey) & 0x8000) != 0;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user