Compare commits
45 Commits
ios-voice-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -14,9 +14,8 @@
|
|||||||
*.exe
|
*.exe
|
||||||
*.pdb
|
*.pdb
|
||||||
|
|
||||||
# vcpkg
|
# vcpkg (bundled as a submodule at /vcpkg — see docs/building.md §2)
|
||||||
/vcpkg_installed/
|
/vcpkg_installed/
|
||||||
/vcpkg/
|
|
||||||
|
|
||||||
# Generated protobuf
|
# Generated protobuf
|
||||||
*.pb.cc
|
*.pb.cc
|
||||||
@@ -36,7 +35,7 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Apple / Windows client build artifacts (added in M4)
|
# Apple / Windows client build artifacts
|
||||||
clients/apple/**/build/
|
clients/apple/**/build/
|
||||||
clients/apple/**/*.xcodeproj/xcuserdata/
|
clients/apple/**/*.xcodeproj/xcuserdata/
|
||||||
clients/apple/**/*.xcodeproj/project.xcworkspace/
|
clients/apple/**/*.xcodeproj/project.xcworkspace/
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "vcpkg"]
|
||||||
|
path = vcpkg
|
||||||
|
url = https://github.com/microsoft/vcpkg.git
|
||||||
12
AGENTS.md
12
AGENTS.md
@@ -38,15 +38,20 @@ making progress.
|
|||||||
|
|
||||||
## Build
|
## 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
|
```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 --preset dev
|
||||||
cmake --build --preset dev
|
cmake --build --preset dev
|
||||||
ctest --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):
|
Skeleton (no third-party deps — works immediately, no vcpkg needed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -68,7 +73,8 @@ for the full matrix.
|
|||||||
|
|
||||||
`vcpkg.json` pins all deps to a fixed vcpkg baseline — `cmake --preset dev` resolves them
|
`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
|
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)
|
## Where each subsystem lives (and its doc)
|
||||||
|
|
||||||
|
|||||||
20
CLAUDE.md
20
CLAUDE.md
@@ -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
|
> server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows
|
||||||
> WinForms C# client shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj`
|
> WinForms C# client shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj`
|
||||||
> at `clients/apple/macOS/`. **iOS SwiftUI client shipped** — `VoiceCatiOS.xcodeproj` at
|
> at `clients/apple/macOS/`. **iOS SwiftUI client shipped** — `VoiceCatiOS.xcodeproj` at
|
||||||
> `clients/apple/iOS/`. `ctest --preset dev` green — 24/24 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.
|
> 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
|
> **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)
|
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
|
+ 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
|
Windows, `x64-linux` on Linux, `arm64-osx` on Apple Silicon. See docs/building.md §1
|
||||||
"Platform matrix" for details.
|
"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
|
```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
|
export VCPKG_ROOT=/path/to/vcpkg # works on Linux / macOS / Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ cmake_minimum_required(VERSION 3.25)
|
|||||||
project(voicecat
|
project(voicecat
|
||||||
VERSION 0.0.1
|
VERSION 0.0.1
|
||||||
DESCRIPTION "Self-hosted native voice & text chat (see docs/)"
|
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
|
# 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
|
# 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()
|
endif()
|
||||||
|
|
||||||
# ── Options ───────────────────────────────────────────────────────────────────
|
# ── 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_SERVER "Build voicecat-server" ON)
|
||||||
option(VOICECAT_BUILD_TOOLS "Build the vccli headless test client" ON)
|
option(VOICECAT_BUILD_TOOLS "Build the vccli headless test client" ON)
|
||||||
option(VOICECAT_BUILD_TESTS "Build tests" 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_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
|
||||||
set(CMAKE_ARCHIVE_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 ───────────────────────────────────────────────────────────────────
|
# ── Targets ───────────────────────────────────────────────────────────────────
|
||||||
add_subdirectory(core)
|
add_subdirectory(core)
|
||||||
|
|
||||||
@@ -45,9 +55,7 @@ endif()
|
|||||||
|
|
||||||
if(VOICECAT_BUILD_TOOLS)
|
if(VOICECAT_BUILD_TOOLS)
|
||||||
add_subdirectory(tools/vccli)
|
add_subdirectory(tools/vccli)
|
||||||
if(VOICECAT_USE_VCPKG_DEPS)
|
|
||||||
add_subdirectory(tools/voicecat-admin)
|
add_subdirectory(tools/voicecat-admin)
|
||||||
endif()
|
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if(VOICECAT_BUILD_TESTS)
|
if(VOICECAT_BUILD_TESTS)
|
||||||
@@ -56,5 +64,4 @@ if(VOICECAT_BUILD_TESTS)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
message(STATUS "VoiceCat ${PROJECT_VERSION} configured "
|
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})")
|
|
||||||
|
|||||||
@@ -5,28 +5,17 @@
|
|||||||
{
|
{
|
||||||
"name": "vcpkg-common",
|
"name": "vcpkg-common",
|
||||||
"hidden": true,
|
"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",
|
"generator": "Ninja",
|
||||||
"toolchainFile": "${sourceDir}/cmake/voicecat-toolchain.cmake",
|
"toolchainFile": "${sourceDir}/cmake/voicecat-toolchain.cmake",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"VOICECAT_USE_VCPKG_DEPS": "ON"
|
"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",
|
"name": "dev",
|
||||||
"displayName": "Dev (full real-deps build, vcpkg)",
|
"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",
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/dev",
|
"binaryDir": "${sourceDir}/build/dev",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
@@ -38,7 +27,7 @@
|
|||||||
{
|
{
|
||||||
"name": "release",
|
"name": "release",
|
||||||
"displayName": "Release (optimized, tests on, symbols kept)",
|
"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",
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/release",
|
"binaryDir": "${sourceDir}/build/release",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
@@ -50,7 +39,7 @@
|
|||||||
{
|
{
|
||||||
"name": "server-release",
|
"name": "server-release",
|
||||||
"displayName": "Server Release (optimized, stripped, no tests)",
|
"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",
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/server-release",
|
"binaryDir": "${sourceDir}/build/server-release",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
@@ -63,7 +52,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "windows-client",
|
"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.",
|
"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",
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/windows-client",
|
"binaryDir": "${sourceDir}/build/windows-client",
|
||||||
@@ -78,7 +67,7 @@
|
|||||||
{
|
{
|
||||||
"name": "apple-dev",
|
"name": "apple-dev",
|
||||||
"displayName": "Apple macOS (libvoicecat.a for Swift Package, scaffolding)",
|
"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",
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/apple-dev",
|
"binaryDir": "${sourceDir}/build/apple-dev",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
@@ -91,7 +80,7 @@
|
|||||||
{
|
{
|
||||||
"name": "apple-ios",
|
"name": "apple-ios",
|
||||||
"displayName": "Apple iOS device (XCFramework slice)",
|
"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",
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/apple-ios",
|
"binaryDir": "${sourceDir}/build/apple-ios",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
@@ -111,7 +100,7 @@
|
|||||||
{
|
{
|
||||||
"name": "apple-ios-sim",
|
"name": "apple-ios-sim",
|
||||||
"displayName": "Apple iOS simulator (XCFramework slice)",
|
"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",
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/apple-ios-sim",
|
"binaryDir": "${sourceDir}/build/apple-ios-sim",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
@@ -130,7 +119,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"buildPresets": [
|
"buildPresets": [
|
||||||
{ "name": "skeleton", "configurePreset": "skeleton" },
|
|
||||||
{ "name": "dev", "configurePreset": "dev" },
|
{ "name": "dev", "configurePreset": "dev" },
|
||||||
{ "name": "release", "configurePreset": "release" },
|
{ "name": "release", "configurePreset": "release" },
|
||||||
{ "name": "server-release", "configurePreset": "server-release" },
|
{ "name": "server-release", "configurePreset": "server-release" },
|
||||||
@@ -140,7 +128,6 @@
|
|||||||
{ "name": "apple-ios-sim", "configurePreset": "apple-ios-sim" }
|
{ "name": "apple-ios-sim", "configurePreset": "apple-ios-sim" }
|
||||||
],
|
],
|
||||||
"testPresets": [
|
"testPresets": [
|
||||||
{ "name": "skeleton", "configurePreset": "skeleton", "output": { "outputOnFailure": true } },
|
|
||||||
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
||||||
{ "name": "release", "configurePreset": "release", "output": { "outputOnFailure": true } }
|
{ "name": "release", "configurePreset": "release", "output": { "outputOnFailure": true } }
|
||||||
]
|
]
|
||||||
|
|||||||
540
PROGRESS.md
540
PROGRESS.md
@@ -10,8 +10,490 @@ up instantly. Newest status at the top.
|
|||||||
|
|
||||||
## ▶ Where we left off / next action
|
## ▶ Where we left off / next action
|
||||||
|
|
||||||
- **Awaiting on-device verification (2026-06-22):** **iOS real echo cancellation / noise suppression
|
- **Done (2026-07-23):** **First comment-density cleanup across core, server, and native
|
||||||
via native VPIO.** Root cause of "voice chat doesn't sound like a call" (echo + no NR): real iOS
|
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
|
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
|
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
|
playback to a native Swift `AVAudioEngine` (`setVoiceProcessingEnabled`) on the AEC presets, with the
|
||||||
@@ -148,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
|
SUCCEEDED (sim slice still arm64-only → simulator run N/A). Next: on-device check of the
|
||||||
drill-down + compose box + unified timeline.
|
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
|
- **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
|
(`ScreenAudioCapture.swift`) → `vc_stream_feed_pcm`; iOS uses a ReplayKit Broadcast Upload
|
||||||
Extension (`VoiceCatBroadcast`) that forwards captured `.audioApp` PCM through a shared App
|
Extension (`VoiceCatBroadcast`) that forwards captured `.audioApp` PCM through a shared App
|
||||||
@@ -381,7 +878,33 @@ up instantly. Newest status at the top.
|
|||||||
|
|
||||||
## Recent completed work
|
## 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.
|
- **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.
|
Promotes `vc_test_inject_capture` (mono-only, TEST-ONLY) to a public, stereo-capable API.
|
||||||
@@ -547,7 +1070,8 @@ text, device pickers, level meter on each platform.
|
|||||||
|
|
||||||
**Windows** (`clients/windows/`): `VoiceCat.Interop` (P/Invoke, `[UnmanagedCallersOnly]`),
|
**Windows** (`clients/windows/`): `VoiceCat.Interop` (P/Invoke, `[UnmanagedCallersOnly]`),
|
||||||
`VoiceCat.App` (ConnectDialog, ServerIdentityDialog, MainForm with full M5 moderation UI,
|
`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,
|
**macOS** (`clients/apple/macOS/VoiceCatMac.xcodeproj`): NSOutlineView channel tree,
|
||||||
NSTableView user list, NSTextView chat, voice controls, full VoiceOver accessibility, admin
|
NSTableView user list, NSTextView chat, voice controls, full VoiceOver accessibility, admin
|
||||||
@@ -585,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:
|
reconstructs the lost frame — otherwise falls back to standard PLC. New test:
|
||||||
`test_dred_toggle` (ctest 22/22). Files: `voicecat.proto`, `voicecat.h`,
|
`test_dred_toggle` (ctest 22/22). Files: `voicecat.proto`, `voicecat.h`,
|
||||||
`opus_codec.{h,cpp}`, `audio_engine.{h,cpp}`, `client.cpp`, `session.{h,cpp}`.
|
`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
|
- [ ] **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`
|
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
|
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).
|
with real vcpkg deps. It works on Windows, Linux, and macOS (vcpkg triplet auto-resolved).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# one-time vcpkg setup:
|
# one-time vcpkg setup (bundled as a submodule, pinned to vcpkg.json's builtin-baseline):
|
||||||
git clone https://github.com/microsoft/vcpkg && ./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
git submodule update --init vcpkg
|
||||||
export VCPKG_ROOT=/path/to/vcpkg # Linux/macOS; or $env:VCPKG_ROOT on PowerShell
|
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||||
|
|
||||||
# configure + build + test:
|
# configure + build + test:
|
||||||
cmake --preset dev
|
cmake --preset dev
|
||||||
@@ -33,6 +33,9 @@ cmake --build --preset dev
|
|||||||
ctest --preset dev # 21 behavior tests
|
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`).
|
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
|
The `skeleton` preset (no vcpkg deps, stubs only) is a fast smoke check that needs no
|
||||||
|
|||||||
BIN
assets/sounds/channel_join.wav
Normal file
BIN
assets/sounds/channel_join.wav
Normal file
Binary file not shown.
BIN
assets/sounds/channel_leave.wav
Normal file
BIN
assets/sounds/channel_leave.wav
Normal file
Binary file not shown.
BIN
assets/sounds/channel_recv.wav
Normal file
BIN
assets/sounds/channel_recv.wav
Normal file
Binary file not shown.
BIN
assets/sounds/channel_sent.wav
Normal file
BIN
assets/sounds/channel_sent.wav
Normal file
Binary file not shown.
BIN
assets/sounds/connection_lost.wav
Normal file
BIN
assets/sounds/connection_lost.wav
Normal file
Binary file not shown.
BIN
assets/sounds/login.wav
Normal file
BIN
assets/sounds/login.wav
Normal file
Binary file not shown.
BIN
assets/sounds/logout.wav
Normal file
BIN
assets/sounds/logout.wav
Normal file
Binary file not shown.
BIN
assets/sounds/pm_recv.wav
Normal file
BIN
assets/sounds/pm_recv.wav
Normal file
Binary file not shown.
BIN
assets/sounds/pm_sent.wav
Normal file
BIN
assets/sounds/pm_sent.wav
Normal file
Binary file not shown.
BIN
assets/sounds/ptt.wav
Normal file
BIN
assets/sounds/ptt.wav
Normal file
Binary file not shown.
BIN
assets/sounds/va_start.wav
Normal file
BIN
assets/sounds/va_start.wav
Normal file
Binary file not shown.
BIN
assets/sounds/va_stop.wav
Normal file
BIN
assets/sounds/va_stop.wav
Normal file
Binary file not shown.
BIN
assets/sounds/voice_off.wav
Normal file
BIN
assets/sounds/voice_off.wav
Normal file
Binary file not shown.
BIN
assets/sounds/voice_on.wav
Normal file
BIN
assets/sounds/voice_on.wav
Normal file
Binary file not shown.
@@ -40,7 +40,11 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "VoiceCatCore",
|
name: "VoiceCatCore",
|
||||||
dependencies: ["VoiceCatCoreXCF"],
|
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/
|
// Smoke tests against a real voicecat-server — mirrors clients/windows/
|
||||||
// VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs. Requires the `dev` CMake preset
|
// VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs. Requires the `dev` CMake preset
|
||||||
|
|||||||
@@ -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
|
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,
|
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/`.
|
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
|
and the vendored RNNoise noise-suppression lib (`third_party/rnnoise/`, built as a CMake
|
||||||
`build-xcframework.sh` merges them all into a single self-contained `libvoicecat-fat.a`
|
target → `build/<preset>/lib/librnnoise.a`) is another. A Swift Package binary target can
|
||||||
(~30 MB) using `libtool -static`. This is the Apple equivalent of how the Windows client
|
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
|
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
|
### Swift Package
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
// Callbacks — the C function pointers passed to `vc_callbacks`. These are the Swift
|
// C callbacks use an unretained `user` context. Client destruction joins callback threads,
|
||||||
// equivalent of the C# client's `[UnmanagedCallersOnly]` static methods (NativeCallbacks.cs).
|
// and transient event pointers are copied before the callback returns.
|
||||||
//
|
|
||||||
// The critical patterns (carried over from the proven C# implementation):
|
|
||||||
// 1. `@convention(c)` closures — plain C function pointers, NOT GC/ARC-managed closures.
|
|
||||||
// A @convention(c) closure cannot capture context, which is why the `user` pointer is
|
|
||||||
// used to resolve back to the VoiceCatClient instance (the C# version uses GCHandle for
|
|
||||||
// the same thing; Swift uses Unmanaged).
|
|
||||||
// 2. `Unmanaged.passUnretained(self).toOpaque()` as the `user` context — a stable raw
|
|
||||||
// pointer to the Swift object WITHOUT incrementing the retain count. This is safe
|
|
||||||
// because `deinit` calls `vc_client_destroy` (which synchronously joins every internal
|
|
||||||
// thread) BEFORE the object's memory is freed — so no callback can fire after the object
|
|
||||||
// is gone. (The C# equivalent: GCHandle.Alloc + GCHandle.Free in Dispose.)
|
|
||||||
// 3. Copy `ev.text` to a Swift `String` INSIDE `onEvent` (via `VoiceCatEvent.from(_:)`)
|
|
||||||
// before returning — the raw pointer is dangling after the callback returns. This is
|
|
||||||
// the #1 lifetime rule from voicecat.h's vc_event doc comment.
|
|
||||||
|
|
||||||
import VoiceCatC
|
import VoiceCatC
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public enum VoiceCatConnectionState: UInt32, Sendable, Equatable {
|
|||||||
case tlsHandshake = 2
|
case tlsHandshake = 2
|
||||||
case authenticating = 3
|
case authenticating = 3
|
||||||
case connected = 4
|
case connected = 4
|
||||||
/// M4: handshake succeeded, waiting on `confirmServerIdentity()`.
|
/// Handshake succeeded, waiting on `confirmServerIdentity()`.
|
||||||
case verifyingIdentity = 5
|
case verifyingIdentity = 5
|
||||||
|
|
||||||
public init(_ cValue: vc_connection_state) {
|
public init(_ cValue: vc_connection_state) {
|
||||||
@@ -125,14 +125,16 @@ public enum VoiceCatEventType: UInt32, Sendable, Equatable {
|
|||||||
case talkState = 9
|
case talkState = 9
|
||||||
case error = 10
|
case error = 10
|
||||||
case disconnected = 11
|
case disconnected = 11
|
||||||
/// M4: reply to `joinChannel()` — see `VoiceCatEvent.result` / `.channelId`.
|
/// Reply to `joinChannel()` — see `VoiceCatEvent.result` / `.channelId`.
|
||||||
case joinResult = 12
|
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
|
case serverIdentity = 13
|
||||||
/// M5: async result for moderation/admin/channel operations.
|
/// Async result for moderation/admin/channel operations.
|
||||||
case genericResult = 14
|
case genericResult = 14
|
||||||
/// M5: reply to `requestAccountList()` — call `listAccounts()` to read.
|
/// Reply to `requestAccountList()` — call `listAccounts()` to read.
|
||||||
case accountList = 15
|
case accountList = 15
|
||||||
|
/// Voice-plane subscription state. `u32a` = 1 (subscribed) or 0 (unsubscribed).
|
||||||
|
case voiceState = 16
|
||||||
|
|
||||||
public init(_ cValue: vc_event_type) {
|
public init(_ cValue: vc_event_type) {
|
||||||
self = VoiceCatEventType(rawValue: cValue.rawValue) ?? .error
|
self = VoiceCatEventType(rawValue: cValue.rawValue) ?? .error
|
||||||
|
|||||||
105
clients/apple/Sources/VoiceCatCore/Feedback/EventFeedback.swift
Normal file
105
clients/apple/Sources/VoiceCatCore/Feedback/EventFeedback.swift
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
43
clients/apple/Sources/VoiceCatCore/Feedback/SoundEvent.swift
Normal file
43
clients/apple/Sources/VoiceCatCore/Feedback/SoundEvent.swift
Normal file
@@ -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
|
let c = items.advanced(by: i).pointee
|
||||||
result.append(Channel(id: c.id, parentId: c.parent_id, name: string(c.name),
|
result.append(Channel(id: c.id, parentId: c.parent_id, name: string(c.name),
|
||||||
topic: string(c.topic), passwordProtected: c.password_protected != 0,
|
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)
|
vc_free_channel_list(&list)
|
||||||
return result
|
return result
|
||||||
@@ -53,7 +54,8 @@ internal enum Marshaling {
|
|||||||
result.append(User(id: u.id, nickname: string(u.nickname), isGuest: u.is_guest != 0,
|
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,
|
channelId: u.channel_id, selfMicMuted: u.self_mic_muted != 0,
|
||||||
selfDeafened: u.self_deafened != 0, serverMuted: u.server_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)
|
vc_free_user_list(&list)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -16,11 +16,17 @@ public struct Channel: Sendable, Equatable, Identifiable {
|
|||||||
public let passwordProtected: Bool
|
public let passwordProtected: Bool
|
||||||
/// 0 = unlimited.
|
/// 0 = unlimited.
|
||||||
public let maxUsers: UInt32
|
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,
|
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.id = id; self.parentId = parentId; self.name = name; self.topic = topic
|
||||||
self.passwordProtected = passwordProtected; self.maxUsers = maxUsers
|
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 selfDeafened: Bool
|
||||||
public let serverMuted: Bool
|
public let serverMuted: Bool
|
||||||
public let serverDeafened: Bool
|
public let serverDeafened: Bool
|
||||||
|
public let voiceSubscribed: Bool
|
||||||
|
|
||||||
public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32,
|
public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32,
|
||||||
selfMicMuted: Bool, selfDeafened: Bool, serverMuted: Bool,
|
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.id = id; self.nickname = nickname; self.isGuest = isGuest; self.channelId = channelId
|
||||||
self.selfMicMuted = selfMicMuted; self.selfDeafened = selfDeafened
|
self.selfMicMuted = selfMicMuted; self.selfDeafened = selfDeafened
|
||||||
self.serverMuted = serverMuted; self.serverDeafened = serverDeafened
|
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 struct Permissions: Sendable, Equatable {
|
||||||
public let canCreateTempChannel: Bool
|
public let canCreateTempChannel: Bool
|
||||||
public let canKick: 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 struct Account: Sendable, Equatable {
|
||||||
public let username: String
|
public let username: String
|
||||||
public let isAdmin: Bool
|
public let isAdmin: Bool
|
||||||
|
|||||||
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_join.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_join.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_leave.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_leave.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_recv.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_recv.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_sent.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_sent.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/connection_lost.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/connection_lost.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/login.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/login.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/logout.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/logout.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/pm_recv.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/pm_recv.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/pm_sent.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/pm_sent.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/ptt.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/ptt.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/va_start.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/va_start.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/va_stop.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/va_stop.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/voice_off.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/voice_off.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/voice_on.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/voice_on.wav
Normal file
Binary file not shown.
@@ -1,36 +1,6 @@
|
|||||||
// VoiceCatClient — the public, Swift-idiomatic surface over libvoicecat. This is the Swift
|
// Swift binding invariants: native config strings outlive the handle, destroy joins callback
|
||||||
// analog of the C# client's `VoiceCatClient.cs` (clients/windows/VoiceCat.Interop).
|
// threads before deallocation, and callback payloads are copied before main-queue delivery.
|
||||||
//
|
// See docs/architecture.md §4 for the complete binding contract.
|
||||||
// Key patterns carried over from the proven C# implementation (see docs/architecture.md §4
|
|
||||||
// per-platform binding notes):
|
|
||||||
//
|
|
||||||
// 1. HANDLE OWNERSHIP: the class owns `vc_client*`; `deinit` calls `vc_client_destroy`
|
|
||||||
// (which synchronously joins every internal thread, so nothing can still be reading the
|
|
||||||
// config-string pointers or firing callbacks by the time it returns).
|
|
||||||
//
|
|
||||||
// 2. CONFIG STRING LIFETIMES: the core stores raw pointers from `vc_config` by value — it
|
|
||||||
// does NOT copy the string data. `client_name`/`client_version`/`tofu_store_path` are
|
|
||||||
// read later, whenever `connect()` actually runs on the io_thread_. So the native CString
|
|
||||||
// storage (`_clientNamePtr` etc.) must outlive the WHOLE client, not just `init`. It's
|
|
||||||
// freed in `deinit`, AFTER `vc_client_destroy` has returned. (C#: Marshal.StringToCoTask
|
|
||||||
// MemUTF8 in ctor, FreeCoTaskMem in Dispose after destroy.)
|
|
||||||
//
|
|
||||||
// 3. EVENT DELIVERY THREAD HANDOFF: `on_event` fires on the core's event thread. Events are
|
|
||||||
// buffered in a lock-protected array and drained on `DispatchQueue.main` — this is the
|
|
||||||
// boundary where the core's thread hands off to the UI thread. The C# analog is
|
|
||||||
// `Channel<VoiceCatEvent>` drained by a 30ms WinForms Timer; the Swift analog is a
|
|
||||||
// coalesced main-queue drain (only one async block scheduled at a time). `on_event`'s
|
|
||||||
// `text` is copied to a Swift `String` inside the callback (Callbacks.swift) before
|
|
||||||
// enqueueing — the raw pointer is dangling by the time the main thread drains.
|
|
||||||
//
|
|
||||||
// 4. LEVEL METER COALESCING: `on_level` fires far more often than `on_event` and
|
|
||||||
// intermediate values are visually irrelevant — coalesced to "latest sample per
|
|
||||||
// stream_id" in a lock-protected dictionary, drained on main alongside events.
|
|
||||||
// (C#: ConcurrentDictionary<uint,float> cleared in PumpEvents.)
|
|
||||||
//
|
|
||||||
// 5. IMMEDIATE vc_free_* ON LIST READS: `listChannels()`/`listUsers()`/etc. walk the native
|
|
||||||
// array, convert to Swift value types, and call `vc_free_*_list` INSIDE the function —
|
|
||||||
// callers never manage native list lifetime. (C#: Marshaling.ToManaged does the same.)
|
|
||||||
|
|
||||||
import VoiceCatC
|
import VoiceCatC
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -56,26 +26,14 @@ public final class VoiceCatClient {
|
|||||||
|
|
||||||
// MARK: - Stored properties
|
// 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?
|
private var handle: OpaquePointer?
|
||||||
|
|
||||||
/// Unmanaged pointer to `self` — passed as `vc_callbacks.user` so the C function-pointer
|
/// Unretained callback context; destroying the handle joins callback threads first.
|
||||||
/// callbacks can resolve back to this instance. `passUnretained` (not `passRetained`)
|
|
||||||
/// because we want normal ARC to control the object's lifetime — `deinit` calls
|
|
||||||
/// `vc_client_destroy` (joins all threads) before the object's memory is freed, so no
|
|
||||||
/// callback can fire with a dangling `user` pointer. See Callbacks.swift.
|
|
||||||
///
|
|
||||||
/// Computed (not stored) to break a circular init dependency: it needs `self`, but
|
|
||||||
/// stored properties must be initialized before `self` is available. `Unmanaged.passUn
|
|
||||||
/// retained(self).toOpaque()` always returns the same address for a given instance, so
|
|
||||||
/// computing it on demand is safe and consistent.
|
|
||||||
private var selfPointer: UnsafeMutableRawPointer {
|
private var selfPointer: UnsafeMutableRawPointer {
|
||||||
Unmanaged.passUnretained(self).toOpaque()
|
Unmanaged.passUnretained(self).toOpaque()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Native CString storage backing `vc_config` — must outlive the whole client (the core
|
/// The core retains these pointers for the handle's lifetime.
|
||||||
/// stores raw pointers, doesn't copy). Freed in `deinit` after `vc_client_destroy`.
|
|
||||||
private var clientNamePtr: UnsafeMutablePointer<CChar>?
|
private var clientNamePtr: UnsafeMutablePointer<CChar>?
|
||||||
private var clientVersionPtr: UnsafeMutablePointer<CChar>?
|
private var clientVersionPtr: UnsafeMutablePointer<CChar>?
|
||||||
private var tofuStorePathPtr: UnsafeMutablePointer<CChar>?
|
private var tofuStorePathPtr: UnsafeMutablePointer<CChar>?
|
||||||
@@ -90,7 +48,6 @@ public final class VoiceCatClient {
|
|||||||
/// Intermediate values are coalesced (only the latest per stream_id is delivered).
|
/// Intermediate values are coalesced (only the latest per stream_id is delivered).
|
||||||
public var onLevel: ((UInt32, Float) -> Void)?
|
public var onLevel: ((UInt32, Float) -> Void)?
|
||||||
|
|
||||||
/// Lock-protected buffers, written from the core's event thread, drained on main.
|
|
||||||
private let bufferLock = NSLock()
|
private let bufferLock = NSLock()
|
||||||
private var eventBuffer: [VoiceCatEvent] = []
|
private var eventBuffer: [VoiceCatEvent] = []
|
||||||
private var levelSamples: [UInt32: Float] = [:]
|
private var levelSamples: [UInt32: Float] = [:]
|
||||||
@@ -98,19 +55,12 @@ public final class VoiceCatClient {
|
|||||||
|
|
||||||
// MARK: - Init / deinit
|
// 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) {
|
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.clientNamePtr = strdup(config.clientName)
|
||||||
self.clientVersionPtr = strdup(config.clientVersion)
|
self.clientVersionPtr = strdup(config.clientVersion)
|
||||||
self.tofuStorePathPtr = config.tofuStorePath.flatMap { strdup($0) }
|
self.tofuStorePathPtr = config.tofuStorePath.flatMap { strdup($0) }
|
||||||
self.handle = nil // placeholder — set below after callbacks are wired
|
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()
|
var nativeConfig = vc_config()
|
||||||
nativeConfig.client_name = UnsafePointer(clientNamePtr)
|
nativeConfig.client_name = UnsafePointer(clientNamePtr)
|
||||||
nativeConfig.client_version = UnsafePointer(clientVersionPtr)
|
nativeConfig.client_version = UnsafePointer(clientVersionPtr)
|
||||||
@@ -219,7 +169,7 @@ public final class VoiceCatClient {
|
|||||||
VoiceCatResult(vc_authenticate_user(handle, username, password))
|
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`
|
/// Accept or reject the pending server-identity check. Call after a `.serverIdentity`
|
||||||
/// event. `accept=true` on firstConnect/mismatch updates the pin file and proceeds;
|
/// event. `accept=true` on firstConnect/mismatch updates the pin file and proceeds;
|
||||||
@@ -256,6 +206,16 @@ public final class VoiceCatClient {
|
|||||||
VoiceCatResult(vc_leave_channel(handle))
|
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`/
|
/// Pull the current channel tree. Re-call after `.channelList`/`.userJoined`/`.userLeft`/
|
||||||
/// `.userUpdated` events. The native list is freed inside this call — callers never
|
/// `.userUpdated` events. The native list is freed inside this call — callers never
|
||||||
/// manage native lifetime.
|
/// manage native lifetime.
|
||||||
@@ -403,12 +363,29 @@ public final class VoiceCatClient {
|
|||||||
|
|
||||||
/// Global playback volume applied after mixing all remote streams. gain 0.0 = silent,
|
/// 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
|
/// 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
|
@discardableResult
|
||||||
public func setOutputVolume(_ gain: Float) -> VoiceCatResult {
|
public func setOutputVolume(_ gain: Float) -> VoiceCatResult {
|
||||||
VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain))
|
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)
|
// MARK: - AVAudioSession interruption hooks (iOS)
|
||||||
|
|
||||||
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
|
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
|
||||||
@@ -473,7 +450,7 @@ public final class VoiceCatClient {
|
|||||||
return Marshaling.devices(&native)
|
return Marshaling.devices(&native)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - M5: Moderation
|
// MARK: - Moderation
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func kickUser(_ userId: UInt32, reason: String? = nil) -> VoiceCatResult {
|
public func kickUser(_ userId: UInt32, reason: String? = nil) -> VoiceCatResult {
|
||||||
@@ -508,7 +485,7 @@ public final class VoiceCatClient {
|
|||||||
VoiceCatResult(vc_move_user(handle, userId, channelId))
|
VoiceCatResult(vc_move_user(handle, userId, channelId))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - M5: Channel admin
|
// MARK: - Channel admin
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func createChannel(_ info: ChannelEdit) -> VoiceCatResult {
|
public func createChannel(_ info: ChannelEdit) -> VoiceCatResult {
|
||||||
@@ -531,7 +508,7 @@ public final class VoiceCatClient {
|
|||||||
VoiceCatResult(vc_delete_channel(handle, channelId))
|
VoiceCatResult(vc_delete_channel(handle, channelId))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - M5: Account admin
|
// MARK: - Account admin
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func createAccount(_ username: String, password: String) -> VoiceCatResult {
|
public func createAccount(_ username: String, password: String) -> VoiceCatResult {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ private final class ServerHarness {
|
|||||||
}
|
}
|
||||||
self.port = port
|
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)
|
let adminURL = URL(fileURLWithPath: repoRoot)
|
||||||
.appendingPathComponent("build/dev/bin/voicecat-admin")
|
.appendingPathComponent("build/dev/bin/voicecat-admin")
|
||||||
guard FileManager.default.isExecutableFile(atPath: adminURL.path) else {
|
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" },
|
XCTAssertTrue(channels.contains { $0.id == 1 && $0.name == "Lobby" },
|
||||||
"expected Lobby (channel 1) in \(channels.map { $0.name })")
|
"expected Lobby (channel 1) in \(channels.map { $0.name })")
|
||||||
|
|
||||||
// M5: permissions getter round-trip.
|
// Permissions getter round-trip.
|
||||||
let perms = client.getPermissions()
|
let perms = client.getPermissions()
|
||||||
XCTAssertFalse(perms.isAdmin)
|
XCTAssertFalse(perms.isAdmin)
|
||||||
XCTAssertFalse(perms.canKick)
|
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.
|
// moderation wrapper path works end-to-end through the Swift interop layer.
|
||||||
events.removeAll()
|
events.removeAll()
|
||||||
XCTAssertEqual(client.requestAccountList(), .ok)
|
XCTAssertEqual(client.requestAccountList(), .ok)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.cat.voice.VoiceCat</string>
|
<string>group.me.iamtalon.voicecat</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000017 /* VoiceCatiOSApp.swift */; };
|
||||||
BBBB00000000000000000031 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000018 /* AppState.swift */; };
|
BBBB00000000000000000031 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000018 /* AppState.swift */; };
|
||||||
BBBB00000000000000000032 /* SessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000019 /* SessionState.swift */; };
|
BBBB00000000000000000032 /* SessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000019 /* SessionState.swift */; };
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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>"; };
|
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>"; };
|
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatiOS.entitlements; sourceTree = "<group>"; };
|
||||||
@@ -138,6 +140,7 @@
|
|||||||
BBBB00000000000000000003 /* VoiceCatiOS */ = {
|
BBBB00000000000000000003 /* VoiceCatiOS */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
AAAA00000000000000000001 /* Assets.xcassets */,
|
||||||
BBBB00000000000000000015 /* Info.plist */,
|
BBBB00000000000000000015 /* Info.plist */,
|
||||||
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */,
|
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */,
|
||||||
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */,
|
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */,
|
||||||
@@ -284,6 +287,7 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
AAAA00000000000000000002 /* Assets.xcassets in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -473,7 +477,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-lc++",
|
"-lc++",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
|
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.9;
|
SWIFT_VERSION = 5.9;
|
||||||
@@ -502,7 +506,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-lc++",
|
"-lc++",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
|
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.9;
|
SWIFT_VERSION = 5.9;
|
||||||
@@ -526,7 +530,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.0.1;
|
MARKETING_VERSION = 0.0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS.broadcast;
|
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat.broadcast;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -551,7 +555,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.0.1;
|
MARKETING_VERSION = 0.0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS.broadcast;
|
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat.broadcast;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Network
|
||||||
import VoiceCatCore
|
import VoiceCatCore
|
||||||
|
|
||||||
struct PendingIdentity: Identifiable {
|
struct PendingIdentity: Identifiable {
|
||||||
@@ -25,6 +26,33 @@ final class AppState {
|
|||||||
private(set) var connectingServer: SavedServer?
|
private(set) var connectingServer: SavedServer?
|
||||||
private var identityHandled = false
|
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
|
// MARK: - Server list management
|
||||||
|
|
||||||
func addServer(_ server: SavedServer, password: String?) {
|
func addServer(_ server: SavedServer, password: String?) {
|
||||||
@@ -54,11 +82,19 @@ final class AppState {
|
|||||||
// MARK: - Connect flow
|
// MARK: - Connect flow
|
||||||
|
|
||||||
func connectTo(_ server: SavedServer) {
|
func connectTo(_ server: SavedServer) {
|
||||||
|
connectTo(server, restoring: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectTo(_ server: SavedServer, restoring: LastSession?) {
|
||||||
guard !isConnecting else { return }
|
guard !isConnecting else { return }
|
||||||
isConnecting = true
|
isConnecting = true
|
||||||
connectStatus = "Connecting…"
|
connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
|
||||||
connectingServer = server
|
connectingServer = server
|
||||||
identityHandled = false
|
identityHandled = false
|
||||||
|
userInitiatedDisconnect = false
|
||||||
|
|
||||||
|
// Releasing the wrapper joins the core's I/O thread before freeing native strings.
|
||||||
|
connectingClient = nil
|
||||||
|
|
||||||
let config = VoiceCatConfig(
|
let config = VoiceCatConfig(
|
||||||
clientName: "VoiceCat-iOS",
|
clientName: "VoiceCat-iOS",
|
||||||
@@ -69,11 +105,14 @@ final class AppState {
|
|||||||
connectingClient = client
|
connectingClient = client
|
||||||
|
|
||||||
client.onEvent = { [weak self] ev in
|
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)
|
client.connect(host: server.host, port: server.port)
|
||||||
|
|
||||||
// Auth is queued immediately — the core serialises it behind TLS + TOFU.
|
|
||||||
switch server.authMode {
|
switch server.authMode {
|
||||||
case .guest:
|
case .guest:
|
||||||
let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
|
let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
|
||||||
@@ -89,13 +128,19 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
session?.stopMicStream()
|
// Set before disconnect so its event cannot arm reconnect.
|
||||||
|
userInitiatedDisconnect = true
|
||||||
|
cancelReconnect()
|
||||||
|
lastSession = nil
|
||||||
|
session?.leaveVoice()
|
||||||
session?.client.disconnect()
|
session?.client.disconnect()
|
||||||
|
IOSAudioEngine.shared.stop()
|
||||||
AudioSessionManager.shared.deactivateSession()
|
AudioSessionManager.shared.deactivateSession()
|
||||||
session = nil
|
session = nil
|
||||||
connectingClient?.disconnect()
|
connectingClient?.disconnect()
|
||||||
connectingClient = nil
|
connectingClient = nil
|
||||||
connectingServer = nil
|
connectingServer = nil
|
||||||
|
connectedServer = nil
|
||||||
isConnecting = false
|
isConnecting = false
|
||||||
connectStatus = ""
|
connectStatus = ""
|
||||||
showPasswordPrompt = false
|
showPasswordPrompt = false
|
||||||
@@ -116,22 +161,139 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cancelConnect() {
|
func cancelConnect() {
|
||||||
|
// User explicitly cancelled — no reconnect for the resulting .disconnected event.
|
||||||
|
userInitiatedDisconnect = true
|
||||||
|
cancelReconnect()
|
||||||
|
lastSession = nil
|
||||||
connectingClient?.disconnect()
|
connectingClient?.disconnect()
|
||||||
connectingClient = nil
|
connectingClient = nil
|
||||||
connectingServer = nil
|
connectingServer = nil
|
||||||
|
connectedServer = nil
|
||||||
isConnecting = false
|
isConnecting = false
|
||||||
connectStatus = ""
|
connectStatus = ""
|
||||||
showPasswordPrompt = false
|
showPasswordPrompt = false
|
||||||
pendingIdentity = nil
|
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
|
// MARK: - Connect event handler
|
||||||
|
|
||||||
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer) {
|
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer,
|
||||||
|
restoring: LastSession?) {
|
||||||
switch ev.type {
|
switch ev.type {
|
||||||
case .connectionState:
|
case .connectionState:
|
||||||
switch ev.connectionState {
|
switch ev.connectionState {
|
||||||
case .connecting: connectStatus = "Connecting…"
|
case .connecting: connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
|
||||||
case .tlsHandshake: connectStatus = "TLS handshake…"
|
case .tlsHandshake: connectStatus = "TLS handshake…"
|
||||||
case .authenticating: connectStatus = "Authenticating…"
|
case .authenticating: connectStatus = "Authenticating…"
|
||||||
case .verifyingIdentity: connectStatus = "Verifying server identity…"
|
case .verifyingIdentity: connectStatus = "Verifying server identity…"
|
||||||
@@ -153,35 +315,86 @@ final class AppState {
|
|||||||
guard let client = connectingClient else { break }
|
guard let client = connectingClient else { break }
|
||||||
let perms = client.getPermissions()
|
let perms = client.getPermissions()
|
||||||
let newSession = SessionState(client: client, selfUserId: ev.userId, permissions: perms)
|
let newSession = SessionState(client: client, selfUserId: ev.userId, permissions: perms)
|
||||||
|
newSession.appState = self
|
||||||
connectingClient = nil
|
connectingClient = nil
|
||||||
isConnecting = false
|
isConnecting = false
|
||||||
connectStatus = ""
|
connectStatus = ""
|
||||||
showPasswordPrompt = false
|
showPasswordPrompt = false
|
||||||
|
connectedServer = server
|
||||||
self.session = newSession
|
self.session = newSession
|
||||||
// Activate the audio session now, while connected — NOT lazily when the first
|
EventFeedback.shared.play(.login)
|
||||||
// remote stream arrives. The core opens its miniaudio playback device the moment
|
EventFeedback.shared.speak(restoring != nil ? "Reconnected" : "Connected")
|
||||||
// a remote stream starts and only THEN emits .streamStarted; if we waited for
|
// External-playback mode was enabled before connect() so the core never opens a
|
||||||
// that event to activate, the playback device would open against an inactive
|
// miniaudio device on iOS (the single ordering rule of the unified audio path).
|
||||||
// AVAudioSession and produce no sound (the "can't hear anyone" bug). Activating
|
// Now activate the session and start the engine in listening mode so remote audio
|
||||||
// here guarantees the session is live before any device opens.
|
// plays the moment someone talks, even before we join voice (no "can't hear anyone").
|
||||||
do {
|
do {
|
||||||
try AudioSessionManager.shared.ensureSessionActive()
|
try AudioSessionManager.shared.ensureSessionActive()
|
||||||
} catch {
|
} catch {
|
||||||
print("Audio session activate on connect failed: \(error)")
|
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 {
|
} else {
|
||||||
connectStatus = "Auth failed: \(ev.result.description)"
|
connectStatus = "Auth failed: \(ev.result.description)"
|
||||||
showPasswordPrompt = true
|
showPasswordPrompt = true
|
||||||
}
|
}
|
||||||
case .disconnected:
|
case .disconnected:
|
||||||
if session == nil { cancelConnect() }
|
// This handler runs ONLY during the connecting phase — after auth success
|
||||||
else {
|
// `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()
|
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:
|
case .error:
|
||||||
connectStatus = ev.text ?? "Unknown error"
|
connectStatus = ev.text ?? "Unknown error"
|
||||||
if session == nil { isConnecting = false }
|
// Errors don't disconnect us; the .disconnected event handles teardown/reconnect.
|
||||||
default:
|
default:
|
||||||
break
|
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,19 +8,6 @@ private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "Audio
|
|||||||
final class AudioSessionManager {
|
final class AudioSessionManager {
|
||||||
static let shared = 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?
|
|
||||||
|
|
||||||
/// Set by `SessionState`. Invoked by `IOSAudioRouter` after an audio-config change so the
|
|
||||||
/// voice path (native VPIO vs the core's miniaudio path) can be restarted to match the new
|
|
||||||
/// preset/route when the mic is active. No-op when not in voice. See
|
|
||||||
/// `SessionState.reconcileVoicePath()` and `IOSVoiceProcessingEngine`.
|
|
||||||
var reconcileVoicePath: (() -> Void)?
|
|
||||||
|
|
||||||
/// Tracks whether WE activated the session. The session must be active whenever the
|
/// 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
|
/// 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
|
/// needs to play (a remote stream started OR the user joins voice) and only deactivated
|
||||||
@@ -28,6 +15,10 @@ final class AudioSessionManager {
|
|||||||
/// want to hear remote audio.
|
/// want to hear remote audio.
|
||||||
private var isSessionActive = false
|
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() {
|
func configure() {
|
||||||
// Load stored audio routing preferences and apply them before any audio session
|
// Load stored audio routing preferences and apply them before any audio session
|
||||||
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
|
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
|
||||||
@@ -44,6 +35,20 @@ final class AudioSessionManager {
|
|||||||
name: AVAudioSession.routeChangeNotification, object: nil)
|
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:
|
/// 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
|
/// 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.
|
/// before the user has joined voice). Idempotent — safe to call multiple times.
|
||||||
@@ -114,22 +119,18 @@ final class AudioSessionManager {
|
|||||||
|
|
||||||
switch type {
|
switch type {
|
||||||
case .began:
|
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")
|
logger.info("interruption began — session suspended by system")
|
||||||
isSessionActive = false // system deactivated us
|
isSessionActive = false
|
||||||
client?.audioSuspend()
|
|
||||||
case .ended:
|
case .ended:
|
||||||
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
// Always attempt recovery when we have a live session. iOS sometimes ends an
|
||||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
// interruption without the `.shouldResume` hint (e.g. Siri), and the previous
|
||||||
if options.contains(.shouldResume) {
|
// behavior of only reactivating when `.shouldResume` was set left the session
|
||||||
do {
|
// permanently dead — audio never came back. `recoverAudio()` is intent-gated on
|
||||||
try AVAudioSession.sharedInstance().setActive(true)
|
// `IOSAudioEngine.isConnected` and idempotent, so speculatively calling it is safe.
|
||||||
isSessionActive = true
|
logger.info("interruption ended — recovery requested")
|
||||||
logger.info("interruption ended — session reactivated")
|
recoverAudio()
|
||||||
client?.audioResume()
|
|
||||||
} catch {
|
|
||||||
logger.error("interruption ended — reactivation failed: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@unknown default: break
|
@unknown default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,34 +140,23 @@ final class AudioSessionManager {
|
|||||||
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
||||||
else {
|
else {
|
||||||
logger.warning("routeChange — unknown reason, refreshing only")
|
logger.warning("routeChange — unknown reason, refreshing + recovery")
|
||||||
IOSAudioRouter.shared.refreshRoutes()
|
IOSAudioRouter.shared.refreshRoutes()
|
||||||
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||||
|
recoverAudio()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("routeChange reason=\(self.reasonLabel(reason))")
|
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()
|
|
||||||
// Re-evaluate the A2DP-mode speaker fallback: a Bluetooth unplug should drop us onto
|
|
||||||
// the loud speaker (not the earpiece), and a replug should hand output back to A2DP.
|
|
||||||
if isSessionActive {
|
|
||||||
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IOSAudioRouter.shared.refreshRoutes()
|
IOSAudioRouter.shared.refreshRoutes()
|
||||||
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
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)))")
|
logSessionState("route change (\(reasonLabel(reason)))")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +169,7 @@ final class AudioSessionManager {
|
|||||||
case .wakeFromSleep: return "wakeFromSleep"
|
case .wakeFromSleep: return "wakeFromSleep"
|
||||||
case .noSuitableRouteForCategory: return "noSuitableRouteForCategory"
|
case .noSuitableRouteForCategory: return "noSuitableRouteForCategory"
|
||||||
case .routeConfigurationChange: return "routeConfigurationChange"
|
case .routeConfigurationChange: return "routeConfigurationChange"
|
||||||
|
case .unknown: return "unknown"
|
||||||
@unknown default: return "unknown"
|
@unknown default: return "unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,47 +4,8 @@ import VoiceCatCore
|
|||||||
|
|
||||||
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter")
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter")
|
||||||
|
|
||||||
/// iOS audio routing layer — drives all iOS audio route selection via `AVAudioSession`
|
/// Owns `AVAudioSession` routing for the iOS external-audio path.
|
||||||
/// *before* the core (miniaudio) opens its device. This class is the sole owner of the
|
/// Route configuration and ordering constraints are documented in `docs/voice.md`.
|
||||||
/// session: miniaudio does NOT touch `AVAudioSession` on iOS, because the core opens its
|
|
||||||
/// devices through a `ma_context` configured with `sessionCategory = none` +
|
|
||||||
/// `noAudioSessionActivate/Deactivate` (see `AudioEngine::make_context_config` in
|
|
||||||
/// `core/src/audio/audio_engine.cpp`). Without that, miniaudio's default path resets the
|
|
||||||
/// category to `Record`/`Playback` with no options on every device open, wiping
|
|
||||||
/// `.allowBluetoothA2DP`/`.playAndRecord` and killing headphone/A2DP output — so that
|
|
||||||
/// config must stay in place. All iOS audio routing (input port selection, mic
|
|
||||||
/// orientation/polar patterns, HFP vs A2DP, measurement/raw mode, stereo capture) must be
|
|
||||||
/// driven from here.
|
|
||||||
///
|
|
||||||
/// The three user-facing choices:
|
|
||||||
/// 1. **Input port** — which physical input (built-in mic, Bluetooth HFP, headset,
|
|
||||||
/// USB, AirPlay). For the built-in mic, a sub-selection of **data source**
|
|
||||||
/// (orientation: front/back/top/bottom) and **polar pattern**
|
|
||||||
/// (omni/cardioid/subcardioid/bidirectional).
|
|
||||||
/// 2. **Bluetooth mode** — how Bluetooth headsets are handled:
|
|
||||||
/// - "BT HFP voice" (`.allowBluetoothHFP` + `.allowBluetoothA2DP`): both profiles
|
|
||||||
/// allowed, iOS picks HFP for two-way mic or A2DP for output-only. Mono, AEC on.
|
|
||||||
/// - "Built-in Mic + BT A2DP stereo" (`.allowBluetoothA2DP` only): stereo output,
|
|
||||||
/// built-in mic, no HFP processing.
|
|
||||||
/// - "Built-in Mic + Speaker" (neither): no Bluetooth at all.
|
|
||||||
/// 3. **Mic processing mode** — Standard (`.voiceChat`: AEC/AGC/HPF on) or
|
|
||||||
/// Raw/Studio (`.measurement`: all processing off). Raw mode is allowed always
|
|
||||||
/// but shows a warning when the output route is the speaker (echo risk, no AEC).
|
|
||||||
///
|
|
||||||
/// Additionally, **stereo capture** (2-channel built-in mic) is enabled by switching the
|
|
||||||
/// built-in mic's data source to the `.stereo` polar pattern. The recipe is:
|
|
||||||
/// `setPreferredDataSource(.stereo source)` + `setPreferredPolarPattern(.stereo)` +
|
|
||||||
/// `setPreferredInput(built-in mic)` + `setInputDataSource(stereo source)`. The channel
|
|
||||||
/// count itself must NOT be requested via `setPreferredInputNumberOfChannels(2)` — that
|
|
||||||
/// session-level call collapses the A2DP output route. Instead the core is told to open the
|
|
||||||
/// device with 2 channels via `vc_set_capture_channels(streamId, 2)`, and the AVAudioSession
|
|
||||||
/// input anchor (`setPreferredInput` + `setInputDataSource`) keeps the route stable during
|
|
||||||
/// the HFP→A2DP and mono→stereo reconfigurations.
|
|
||||||
///
|
|
||||||
/// Voice Isolation / Wide Spectrum (iOS 17+/18+) are user-toggleable in Control Center
|
|
||||||
/// for `.voiceChat` apps — surfaced as a hint, not a programmatic toggle.
|
|
||||||
///
|
|
||||||
/// All choices are persisted in `UserDefaults` and re-applied on route changes.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class IOSAudioRouter: ObservableObject {
|
final class IOSAudioRouter: ObservableObject {
|
||||||
|
|
||||||
@@ -64,77 +25,59 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
@Published var selectedInputPortId: String?
|
@Published var selectedInputPortId: String?
|
||||||
@Published var selectedDataSourceId: String?
|
@Published var selectedDataSourceId: String?
|
||||||
@Published var selectedPolarPattern: 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 showsRawModeSpeakerWarning: Bool = false
|
||||||
@Published var showsA2dpNoAecWarning: Bool = false
|
@Published var showsA2dpNoAecWarning: Bool = false
|
||||||
@Published var hasBluetoothDevice: Bool = false
|
@Published var hasBluetoothDevice: Bool = false
|
||||||
@Published var hasWiredHeadset: Bool = false
|
@Published var hasWiredHeadset: Bool = false
|
||||||
|
|
||||||
/// Audio presets — sensible combinations of settings for common scenarios.
|
/// Audio presets — the four scenarios from the product spec. Pick a preset for a quick start,
|
||||||
/// The app is about choice: users can pick a preset for a quick start, then
|
/// then fine-tune individual settings under "Advanced". HFP / wired headsets are not separate
|
||||||
/// fine-tune individual settings under "Advanced Audio".
|
/// presets: Voice Chat lets the system route to them, and Advanced exposes manual selection.
|
||||||
enum AudioPreset: String, CaseIterable, Identifiable {
|
enum AudioPreset: String, CaseIterable, Identifiable {
|
||||||
/// Standard iOS VoIP experience: AEC/AGC/HPF on, mono, system picks best route
|
/// Voice chat: Apple VPIO does real AEC + noise suppression + AGC. Mono. The system picks
|
||||||
/// (BT HFP if connected, wired if connected, speaker if nothing). Always available.
|
/// the best route (Bluetooth HFP / wired / speaker / earpiece). Always available.
|
||||||
case voiceChat = "Voice Chat"
|
case voiceChat = "Voice Chat"
|
||||||
/// Stereo built-in mic capture (front+back capsules). A2DP output if BT is connected,
|
/// Internal **stereo** built-in mic regardless of the output route. A2DP output when a
|
||||||
/// else built-in speaker / wired. Standard processing (no AEC — stereo needs a non-VPIO
|
/// Bluetooth headset is connected, else built-in speaker / wired. No VPIO (stereo can't
|
||||||
/// mode). Always available.
|
/// use it). Always available.
|
||||||
case stereoMic = "Stereo Mic"
|
case stereoMic = "Stereo Mic"
|
||||||
/// Maximum fidelity: stereo mic, no AEC/AGC/HPF (raw mode). A2DP output if BT connected,
|
/// Internal **mono** built-in mic regardless of the output route. A2DP output when a
|
||||||
/// else speaker/wired. Always available. Echo risk on speaker.
|
/// Bluetooth headset is connected, else built-in speaker / wired. No VPIO. Always available.
|
||||||
case studio = "Studio (No Processing)"
|
case monoMic = "Mono Mic"
|
||||||
/// Bluetooth HFP: BT mic + BT output, AEC on, mono. Only when BT is connected.
|
/// Everything manual — input port, mic orientation / polar pattern, mono/stereo, Bluetooth
|
||||||
case bluetoothHeadset = "Bluetooth Headset (HFP)"
|
/// mode, raw vs standard, and the VPIO / AGC toggles. Also the display state when the
|
||||||
/// A2DP stereo output + built-in mono mic, AEC off. Only when BT is connected. (For
|
/// individual settings don't match a named preset.
|
||||||
/// A2DP output + stereo mic, use the Stereo Mic preset while BT is connected.)
|
case advanced = "Advanced"
|
||||||
case btHeadphonesMonoMic = "BT Headphones + Mono Mic"
|
|
||||||
/// Wired headset/earpods: wired output + wired mic (or built-in), AEC on, mono.
|
|
||||||
/// Only when a wired audio device is connected.
|
|
||||||
case wiredHeadset = "Wired Headset"
|
|
||||||
/// Settings don't match any preset — user has tweaked advanced controls.
|
|
||||||
case custom = "Custom"
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
var requiresBluetooth: Bool {
|
|
||||||
switch self {
|
|
||||||
case .bluetoothHeadset, .btHeadphonesMonoMic: return true
|
|
||||||
default: return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var requiresWired: Bool {
|
|
||||||
self == .wiredHeadset
|
|
||||||
}
|
|
||||||
|
|
||||||
var bluetoothMode: BluetoothMode {
|
var bluetoothMode: BluetoothMode {
|
||||||
switch self {
|
switch self {
|
||||||
case .voiceChat, .bluetoothHeadset: return .btHfpVoice
|
case .voiceChat: return .btHfpVoice
|
||||||
// A2DP output when BT is connected; falls back to speaker/wired when it isn't.
|
// Internal-mic presets: A2DP output when BT is connected; speaker/wired when not.
|
||||||
case .stereoMic, .studio, .btHeadphonesMonoMic: return .builtInMicBtA2dp
|
case .stereoMic, .monoMic: return .builtInMicBtA2dp
|
||||||
case .wiredHeadset: return .builtInMicSpeaker
|
case .advanced: return .builtInMicSpeaker // placeholder; Advanced sets it manually
|
||||||
case .custom: return .builtInMicSpeaker // placeholder
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var captureChannels: CaptureChannels {
|
var captureChannels: CaptureChannels {
|
||||||
switch self {
|
self == .stereoMic ? .stereo : .mono
|
||||||
case .stereoMic, .studio: return .stereo
|
|
||||||
default: return .mono
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var micMode: MicMode {
|
var micMode: MicMode { .standard }
|
||||||
switch self {
|
|
||||||
case .studio: return .raw
|
|
||||||
default: return .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 {
|
var usesBuiltInMic: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .stereoMic, .studio, .btHeadphonesMonoMic: return true
|
case .stereoMic, .monoMic: return true
|
||||||
default: return false
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,14 +113,15 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
private let kPolarPattern = "cat.voice.audio.polarPattern"
|
private let kPolarPattern = "cat.voice.audio.polarPattern"
|
||||||
private let kPreset = "cat.voice.audio.preset"
|
private let kPreset = "cat.voice.audio.preset"
|
||||||
private let kForceSpeaker = "cat.voice.audio.forceSpeaker"
|
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
|
/// AVAudioSession setters can synchronously emit route-change notifications.
|
||||||
/// notifications synchronously on the same thread. Without this guard,
|
|
||||||
/// handleRouteChange → applyConfiguration → setCategory → route-change notification
|
|
||||||
/// → handleRouteChange → applyConfiguration → ... creates an infinite loop that
|
|
||||||
/// burns CPU and cycles the audio session on/off (the "glitching" bug).
|
|
||||||
private var isApplyingConfiguration = false
|
private var isApplyingConfiguration = false
|
||||||
|
|
||||||
|
/// Prevents redundant overrides; `setCategory` invalidates the cached value.
|
||||||
|
private var lastAppliedOutputOverride: AVAudioSession.PortOverride?
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
// MARK: - Load / refresh from AVAudioSession
|
// MARK: - Load / refresh from AVAudioSession
|
||||||
@@ -271,55 +215,41 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The presets available given the current device connection state.
|
/// The presets the user can pick. All four are always available — the named presets simply
|
||||||
/// Always includes Voice Chat, Stereo Mic, Studio, and Custom. BT presets only when
|
/// describe what to do "regardless of the output route", and Advanced is always offered.
|
||||||
/// a Bluetooth device is connected. Wired preset only when a wired device is connected.
|
var availablePresets: [AudioPreset] { AudioPreset.allCases }
|
||||||
var availablePresets: [AudioPreset] {
|
|
||||||
AudioPreset.allCases.filter { preset in
|
|
||||||
if preset == .custom { return true }
|
|
||||||
if preset.requiresBluetooth && !hasBluetoothDevice { return false }
|
|
||||||
if preset.requiresWired && !hasWiredHeadset { return false }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which preset matches the current settings, or .custom if nothing matches.
|
/// Which named preset matches the current settings, or `.advanced` if nothing matches.
|
||||||
/// Checks device-specific presets first (BT, wired) so that e.g. when BT is connected
|
|
||||||
/// and settings match "Bluetooth Headset", it returns that instead of the equivalent
|
|
||||||
/// "Voice Chat" (which has the same bluetoothMode/micMode/channels but is more general).
|
|
||||||
var activePreset: AudioPreset {
|
var activePreset: AudioPreset {
|
||||||
// Check device-specific presets first (most specific → least specific)
|
for preset in [AudioPreset.voiceChat, .stereoMic, .monoMic] {
|
||||||
let order: [AudioPreset] = [
|
|
||||||
.bluetoothHeadset, .btHeadphonesMonoMic,
|
|
||||||
.wiredHeadset,
|
|
||||||
.voiceChat, .stereoMic, .studio,
|
|
||||||
]
|
|
||||||
for preset in order {
|
|
||||||
if bluetoothMode == preset.bluetoothMode
|
if bluetoothMode == preset.bluetoothMode
|
||||||
&& captureChannels == preset.captureChannels
|
&& captureChannels == preset.captureChannels
|
||||||
&& micMode == preset.micMode {
|
&& 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 preset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return .custom
|
return .advanced
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the current configuration should use the native iOS Voice-Processing path (VPIO:
|
/// Whether the current configuration should engage Apple's Voice-Processing I/O unit (VPIO:
|
||||||
/// real AEC/NS/AGC via `IOSVoiceProcessingEngine`). True exactly when `applyConfiguration`
|
/// real AEC + noise suppression + AGC, driven by `IOSAudioEngine`). VPIO forces mono and
|
||||||
/// selects the `.voiceChat` AVAudioSession mode — mono + standard processing + not A2DP
|
/// can't run on an A2DP route, so it is available only for a mono + standard + non-A2DP
|
||||||
/// (A2DP / stereo / raw modes can't use VPIO, so they keep the core's miniaudio path).
|
/// config, and then only when the user hasn't disabled it via the Advanced master toggle.
|
||||||
var currentConfigUsesVoiceProcessing: Bool {
|
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
|
captureChannels == .mono && micMode == .standard && bluetoothMode != .builtInMicBtA2dp
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Apply configuration
|
// MARK: - Apply configuration
|
||||||
|
|
||||||
/// Apply the full audio configuration to AVAudioSession. Call this before the core
|
/// Apply the full audio configuration to AVAudioSession. Call this before (re)building the
|
||||||
/// opens its capture device (i.e. before `startMicStream` → `activateForStreaming`).
|
/// `IOSAudioEngine` graph so the engine binds to the intended route (`applyAndReconfigure`
|
||||||
/// Re-entrant-safe: if a route-change notification fires synchronously during a
|
/// does both). Re-entrant-safe: if a route-change notification fires synchronously during a
|
||||||
/// `setCategory`/`setPreferredInput` call, the guard prevents re-entry.
|
/// `setCategory`/`setPreferredInput` call, the guard prevents re-entry.
|
||||||
func applyConfiguration() {
|
func applyConfiguration() {
|
||||||
guard !isApplyingConfiguration else {
|
guard !isApplyingConfiguration else {
|
||||||
@@ -327,6 +257,9 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
isApplyingConfiguration = true
|
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 }
|
defer { isApplyingConfiguration = false }
|
||||||
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
@@ -397,13 +330,8 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
|
|
||||||
// 3. Input & mic-capsule configuration.
|
// 3. Input & mic-capsule configuration.
|
||||||
if captureChannels == .stereo {
|
if captureChannels == .stereo {
|
||||||
// Stereo: enable the built-in mic's .stereo polar pattern AND anchor the input
|
// See configureStereoCapture's doc comment for the full stereo-capture recipe
|
||||||
// route explicitly via setPreferredInput + setInputDataSource. With HFP disabled
|
// and why each step is necessary.
|
||||||
// the system routes input to the built-in mic, but without the explicit
|
|
||||||
// preferred-input anchor the route can collapse during the mode switch
|
|
||||||
// (.voiceChat → .default) and the output dies. The channel count is requested by
|
|
||||||
// miniaudio at the audio-unit level (vc_set_capture_channels), NOT via
|
|
||||||
// setPreferredInputNumberOfChannels(2) — that call collapses the A2DP output route.
|
|
||||||
configureStereoCapture(session: session)
|
configureStereoCapture(session: session)
|
||||||
} else if let portId = selectedInputPortId, !portId.isEmpty,
|
} else if let portId = selectedInputPortId, !portId.isEmpty,
|
||||||
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
||||||
@@ -424,16 +352,8 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
updateWarnings()
|
updateWarnings()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable 2-channel capture on the built-in mic. The recipe that achieves stereo mic +
|
/// Anchors the built-in stereo data source without using
|
||||||
/// A2DP Bluetooth output simultaneously:
|
/// `setPreferredInputNumberOfChannels`, which disrupts A2DP routing.
|
||||||
/// 1. `setPreferredDataSource(stereoSource)` on the built-in mic port
|
|
||||||
/// 2. `setPreferredPolarPattern(.stereo)` on that data source
|
|
||||||
/// 3. `setPreferredInput(builtIn)` — anchor the input route explicitly. Without this
|
|
||||||
/// anchor the route can collapse during the mode switch (.voiceChat → .default).
|
|
||||||
/// 4. `setInputDataSource(stereoSource)` — commit the data source at the session level
|
|
||||||
/// The channel count itself is requested by miniaudio at the audio-unit level via
|
|
||||||
/// `vc_set_capture_channels(2)`. We must NOT call `setPreferredInputNumberOfChannels(2)`
|
|
||||||
/// — that session-level call collapses the A2DP output route.
|
|
||||||
private func configureStereoCapture(session: AVAudioSession) {
|
private func configureStereoCapture(session: AVAudioSession) {
|
||||||
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
|
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
|
||||||
else {
|
else {
|
||||||
@@ -449,8 +369,6 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
try builtIn.setPreferredDataSource(stereoSource)
|
try builtIn.setPreferredDataSource(stereoSource)
|
||||||
try stereoSource.setPreferredPolarPattern(.stereo)
|
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)
|
try session.setPreferredInput(builtIn)
|
||||||
// Commit the data source at the session level. setPreferredDataSource alone only
|
// Commit the data source at the session level. setPreferredDataSource alone only
|
||||||
// sets the port-level preference; setInputDataSource makes it the active source.
|
// sets the port-level preference; setInputDataSource makes it the active source.
|
||||||
@@ -538,6 +456,9 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId)
|
selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId)
|
||||||
selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern)
|
selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern)
|
||||||
forceSpeaker = UserDefaults.standard.bool(forKey: kForceSpeaker)
|
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.
|
/// Persist current selections to UserDefaults.
|
||||||
func savePreferences() {
|
func savePreferences() {
|
||||||
@@ -548,86 +469,84 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId)
|
UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId)
|
||||||
UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern)
|
UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern)
|
||||||
UserDefaults.standard.set(forceSpeaker, forKey: kForceSpeaker)
|
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)
|
// 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) {
|
func selectInputPort(_ portId: String) {
|
||||||
selectedInputPortId = portId
|
selectedInputPortId = portId
|
||||||
selectedDataSourceId = nil
|
selectedDataSourceId = nil
|
||||||
selectedPolarPattern = nil
|
selectedPolarPattern = nil
|
||||||
savePreferences()
|
applyAndReconfigure()
|
||||||
applyConfiguration()
|
|
||||||
refreshRoutes()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectDataSource(_ dataSourceId: String) {
|
func selectDataSource(_ dataSourceId: String) {
|
||||||
selectedDataSourceId = dataSourceId
|
selectedDataSourceId = dataSourceId
|
||||||
selectedPolarPattern = nil
|
selectedPolarPattern = nil
|
||||||
savePreferences()
|
applyAndReconfigure()
|
||||||
applyConfiguration()
|
|
||||||
refreshRoutes()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectPolarPattern(_ pattern: String) {
|
func selectPolarPattern(_ pattern: String) {
|
||||||
selectedPolarPattern = pattern
|
selectedPolarPattern = pattern
|
||||||
savePreferences()
|
applyAndReconfigure()
|
||||||
applyConfiguration()
|
|
||||||
refreshRoutes()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectBluetoothMode(_ mode: BluetoothMode) {
|
func selectBluetoothMode(_ mode: BluetoothMode) {
|
||||||
bluetoothMode = mode
|
bluetoothMode = mode
|
||||||
savePreferences()
|
applyAndReconfigure()
|
||||||
applyConfiguration()
|
|
||||||
refreshRoutes()
|
|
||||||
// VPIO class or route may have changed — restart the voice path if mic is active.
|
|
||||||
AudioSessionManager.shared.reconcileVoicePath?()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setForceSpeaker(_ on: Bool) {
|
func setForceSpeaker(_ on: Bool) {
|
||||||
forceSpeaker = on
|
forceSpeaker = on
|
||||||
savePreferences()
|
applyAndReconfigure()
|
||||||
applyConfiguration()
|
|
||||||
refreshRoutes()
|
|
||||||
// Route changed under a possibly-running VPIO engine — reconcile if mic is active.
|
|
||||||
AudioSessionManager.shared.reconcileVoicePath?()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectMicMode(_ mode: MicMode) {
|
func selectMicMode(_ mode: MicMode) {
|
||||||
micMode = mode
|
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()
|
savePreferences()
|
||||||
applyConfiguration()
|
IOSAudioEngine.shared.reconfigure()
|
||||||
updateWarnings()
|
|
||||||
// Standard↔Raw flips the VPIO class — reconcile if mic is active.
|
|
||||||
AudioSessionManager.shared.reconcileVoicePath?()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectCaptureChannels(_ channels: CaptureChannels) {
|
func selectCaptureChannels(_ channels: CaptureChannels) {
|
||||||
captureChannels = channels
|
captureChannels = channels
|
||||||
savePreferences()
|
savePreferences()
|
||||||
applyConfiguration()
|
applyConfiguration()
|
||||||
// Update the core's stored capture channel count (does not restart the engine).
|
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
||||||
if let streamId = AudioSessionManager.shared.activeMicStreamId {
|
refreshRoutes()
|
||||||
_ = AudioSessionManager.shared.client?.setCaptureChannels(
|
// Push the channel count into the core's MIC stream, then rebuild the engine graph so the
|
||||||
streamId: streamId, channels: channels.channelCount)
|
// 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.
|
||||||
// Restart the engine AFTER AVAudioSession routing has settled and the channel
|
IOSAudioEngine.shared.setCaptureChannels(channels.channelCount)
|
||||||
// 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()
|
|
||||||
// Mono↔stereo flips the VPIO class (stereo can't use VPIO) — reconcile if mic is active.
|
|
||||||
AudioSessionManager.shared.reconcileVoicePath?()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Presets
|
// MARK: - Presets
|
||||||
|
|
||||||
/// Apply a preset — sets all individual audio settings to the preset's values, then
|
/// Apply a named preset — set all individual settings to the preset's values, then re-apply
|
||||||
/// applies the configuration. For presets that use the built-in mic (A2DP presets),
|
/// the configuration and rebind the engine. The internal-mic presets pin the built-in mic.
|
||||||
/// finds the built-in mic port UID from availableInputs.
|
|
||||||
func applyPreset(_ preset: AudioPreset) {
|
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
|
bluetoothMode = preset.bluetoothMode
|
||||||
micMode = preset.micMode
|
micMode = preset.micMode
|
||||||
@@ -638,19 +557,17 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
if preset == .voiceChat { forceSpeaker = true }
|
if preset == .voiceChat { forceSpeaker = true }
|
||||||
|
|
||||||
if preset.usesBuiltInMic {
|
if preset.usesBuiltInMic {
|
||||||
// Find the built-in mic port from available inputs and select it.
|
// Pin the built-in mic. In stereo, iOS uses multiple capsules automatically; in mono
|
||||||
let session = AVAudioSession.sharedInstance()
|
// the default orientation is fine — so don't force a specific data source / pattern.
|
||||||
if let builtInMic = (session.availableInputs ?? []).first(where: {
|
if let builtInMic = (AVAudioSession.sharedInstance().availableInputs ?? []).first(where: {
|
||||||
$0.portType == .builtInMic
|
$0.portType == .builtInMic
|
||||||
}) {
|
}) {
|
||||||
selectedInputPortId = builtInMic.uid
|
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
|
selectedDataSourceId = nil
|
||||||
selectedPolarPattern = nil
|
selectedPolarPattern = nil
|
||||||
} else {
|
} 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
|
selectedInputPortId = nil
|
||||||
selectedDataSourceId = nil
|
selectedDataSourceId = nil
|
||||||
selectedPolarPattern = nil
|
selectedPolarPattern = nil
|
||||||
@@ -659,18 +576,11 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
UserDefaults.standard.set(preset.rawValue, forKey: kPreset)
|
UserDefaults.standard.set(preset.rawValue, forKey: kPreset)
|
||||||
savePreferences()
|
savePreferences()
|
||||||
applyConfiguration()
|
applyConfiguration()
|
||||||
// Update the core's stored capture channel count (does not restart the engine).
|
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
||||||
if let streamId = AudioSessionManager.shared.activeMicStreamId {
|
|
||||||
_ = AudioSessionManager.shared.client?.setCaptureChannels(
|
|
||||||
streamId: streamId, channels: preset.captureChannels.channelCount)
|
|
||||||
}
|
|
||||||
// Restart the engine AFTER AVAudioSession routing has settled and the channel
|
|
||||||
// count is stored. Playback opens first (commits A2DP route), then capture.
|
|
||||||
_ = AudioSessionManager.shared.client?.audioRestart()
|
|
||||||
refreshRoutes()
|
refreshRoutes()
|
||||||
// The preset may have flipped the VPIO class (and/or the route) — restart the voice path
|
// Push the channel count to the core, then rebuild the engine graph (VPIO on/off + tap).
|
||||||
// if the mic is active so AEC/NS engage (or disengage) to match the new preset.
|
IOSAudioEngine.shared.setCaptureChannels(preset.captureChannels.channelCount)
|
||||||
AudioSessionManager.shared.reconcileVoicePath?()
|
IOSAudioEngine.shared.reconfigure()
|
||||||
logger.info("applyPreset — \(preset.rawValue)")
|
logger.info("applyPreset — \(preset.rawValue)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,15 +599,8 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
showsA2dpNoAecWarning = (bluetoothMode == .builtInMicBtA2dp)
|
showsA2dpNoAecWarning = (bluetoothMode == .builtInMicBtA2dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Route fallback for the A2DP-output presets (Stereo Mic / Studio / BT Headphones + Mono
|
/// Uses the speaker only when an A2DP-capable preset has no external output.
|
||||||
/// Mic, all `.builtInMicBtA2dp`). These presets deliberately omit `.defaultToSpeaker` (it
|
/// The cached override avoids recursively generated route-change notifications.
|
||||||
/// breaks A2DP routing) and skip the `forceSpeaker` override, so when NO external output
|
|
||||||
/// (Bluetooth A2DP / wired / AirPlay) is connected `.playAndRecord` pins output to the quiet
|
|
||||||
/// built-in receiver (earpiece). This routes to the loud built-in speaker instead via a
|
|
||||||
/// post-activation `overrideOutputAudioPort(.speaker)` — the documented "A2DP if connected,
|
|
||||||
/// else speaker" behavior. When an external output IS present we clear the override so A2DP /
|
|
||||||
/// headphones / AirPlay are honored. No-op outside `.builtInMicBtA2dp` mode (other modes pick
|
|
||||||
/// their route via category options). Must be called AFTER the session is active.
|
|
||||||
func applyA2dpSpeakerFallback() {
|
func applyA2dpSpeakerFallback() {
|
||||||
guard bluetoothMode == .builtInMicBtA2dp else { return }
|
guard bluetoothMode == .builtInMicBtA2dp else { return }
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
@@ -706,19 +609,30 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
let hasExternalOutput = session.currentRoute.outputs.contains {
|
let hasExternalOutput = session.currentRoute.outputs.contains {
|
||||||
$0.portType != .builtInReceiver && $0.portType != .builtInSpeaker
|
$0.portType != .builtInReceiver && $0.portType != .builtInSpeaker
|
||||||
}
|
}
|
||||||
do {
|
let desired: AVAudioSession.PortOverride = hasExternalOutput ? .none : .speaker
|
||||||
if hasExternalOutput {
|
if desired == lastAppliedOutputOverride {
|
||||||
try session.overrideOutputAudioPort(.none)
|
logger.debug("A2DP fallback — desired=\(self.overrideLabel(desired)) already applied, skipping")
|
||||||
logger.info("A2DP mode — external output present, clearing speaker override")
|
return
|
||||||
} else {
|
|
||||||
try session.overrideOutputAudioPort(.speaker)
|
|
||||||
logger.info("A2DP mode — no external output, routing to built-in speaker")
|
|
||||||
}
|
}
|
||||||
|
do {
|
||||||
|
try session.overrideOutputAudioPort(desired)
|
||||||
|
lastAppliedOutputOverride = desired
|
||||||
|
logger.info("A2DP mode — override applied: \(self.overrideLabel(desired))")
|
||||||
} catch {
|
} 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)")
|
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.
|
/// The selected input port object, if any.
|
||||||
var selectedPort: IOSAudioInputPort? {
|
var selectedPort: IOSAudioInputPort? {
|
||||||
inputPorts.first(where: { $0.id == selectedInputPortId })
|
inputPorts.first(where: { $0.id == selectedInputPortId })
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import Darwin
|
|||||||
import os
|
import os
|
||||||
import VoiceCatCore
|
import VoiceCatCore
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSVoiceProcessingEngine")
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioEngine")
|
||||||
|
|
||||||
/// In-process single-producer/single-consumer int16 PCM ring for the VPIO playback path.
|
/// 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)
|
/// producer = the core's mixer-timer thread (the `vc_set_mixed_output_sink` callback)
|
||||||
/// consumer = the `AVAudioSourceNode` render thread
|
/// consumer = the `AVAudioSourceNode` render thread
|
||||||
@@ -73,44 +73,88 @@ final class PCMRing {
|
|||||||
return 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.
|
/// Discard everything buffered — call before (re)starting so stale pre-roll isn't played.
|
||||||
func reset() { OSMemoryBarrier(); readIdx = writeIdx }
|
func reset() { OSMemoryBarrier(); readIdx = writeIdx }
|
||||||
|
|
||||||
/// Diagnostics: monotonic total samples written / read since the ring was created. The
|
/// 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
|
/// 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
|
/// 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" (Bug 1
|
/// synchronized for live cross-thread reads. Lets us tell "core never delivered PCM" apart
|
||||||
/// core path) apart from "PCM arrived but produced no sound" (AVAudioEngine output graph).
|
/// from "PCM arrived but produced no sound" (the AVAudioEngine output graph).
|
||||||
var debugTotalWritten: UInt64 { writeIdx }
|
var debugTotalWritten: UInt64 { writeIdx }
|
||||||
var debugTotalRead: UInt64 { readIdx }
|
var debugTotalRead: UInt64 { readIdx }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Native iOS voice-processing audio path (docs/voice.md §8 "iOS voice processing").
|
/// The single iOS audio engine (docs/voice.md §8 "iOS audio engine").
|
||||||
///
|
///
|
||||||
/// Real iOS echo cancellation / noise suppression / AGC come ONLY from Apple's Voice-Processing
|
/// **One path, always external.** On iOS the core never opens a miniaudio device: a MIC stream is
|
||||||
/// I/O unit (VPIO), which `AVAudioEngine.setVoiceProcessingEnabled(true)` enables. For VPIO to
|
/// always started with `external_feed=1`, `vc_set_external_playback(1)` is set once at connect, and
|
||||||
/// cancel echo it must own BOTH the mic capture and the remote-audio playback (it subtracts the
|
/// this engine drives *both* directions through one `AVAudioEngine`:
|
||||||
/// played-back signal from the mic), so on the AEC presets this engine drives both directions and
|
|
||||||
/// the core runs in external mode (no hardware devices):
|
|
||||||
/// - **Mic → core:** a tap on the VPIO input node → 48 kHz int16 → `client.feedPcm(micStreamId)`.
|
|
||||||
/// - **core → speaker:** the core's mixed-output sink fills `ring`; an `AVAudioSourceNode` pulls
|
/// - **core → speaker:** the core's mixed-output sink fills `ring`; an `AVAudioSourceNode` pulls
|
||||||
/// from it and renders through the VPIO output, giving AEC its reference signal.
|
/// 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.
|
||||||
///
|
///
|
||||||
/// Lifecycle is driven by `SessionState` join/leave. The Stereo Mic / Studio / A2DP presets keep
|
/// Echo cancellation / noise suppression / AGC come from Apple's Voice-Processing I/O unit (VPIO),
|
||||||
/// the core's miniaudio path instead (they want raw / stereo / no-AEC routing VPIO can't provide).
|
/// 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
|
@MainActor
|
||||||
final class IOSVoiceProcessingEngine {
|
final class IOSAudioEngine {
|
||||||
static let shared = IOSVoiceProcessingEngine()
|
static let shared = IOSAudioEngine()
|
||||||
|
|
||||||
private(set) var isRunning = false
|
/// 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 let engine = AVAudioEngine()
|
||||||
private var sourceNode: AVAudioSourceNode?
|
private var sourceNode: AVAudioSourceNode?
|
||||||
private weak var client: VoiceCatClient?
|
private weak var client: VoiceCatClient?
|
||||||
private var micStreamId: UInt32 = 0
|
private var micStreamId: UInt32 = 0
|
||||||
|
private var captureChannels: UInt32 = 1
|
||||||
|
|
||||||
// 48 kHz stereo Float32 (deinterleaved) — the format the source node renders and the engine
|
// AVAudioEngine may deliver several codec frames per callback. Pace complete 20 ms frames
|
||||||
// processes in. The core delivers 48 kHz stereo int16 via the mixed-output sink.
|
// 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(
|
private let outFormat = AVAudioFormat(
|
||||||
commonFormat: .pcmFormatFloat32, sampleRate: 48000, channels: 2, interleaved: false)!
|
commonFormat: .pcmFormatFloat32, sampleRate: 48000, channels: 2, interleaved: false)!
|
||||||
|
|
||||||
@@ -121,33 +165,189 @@ final class IOSVoiceProcessingEngine {
|
|||||||
private let renderScratchFrames = 8192
|
private let renderScratchFrames = 8192
|
||||||
private let renderScratch: UnsafeMutablePointer<Int16>
|
private let renderScratch: UnsafeMutablePointer<Int16>
|
||||||
|
|
||||||
// Mic-feed converter (input-node format → 48 kHz int16) and its target buffer. Owned here so
|
|
||||||
// the (background) tap block reuses them instead of allocating per callback.
|
|
||||||
private var micConverter: AVAudioConverter?
|
|
||||||
private var micTargetFormat: AVAudioFormat?
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
renderScratch = UnsafeMutablePointer<Int16>.allocate(capacity: renderScratchFrames * 2)
|
renderScratch = UnsafeMutablePointer<Int16>.allocate(capacity: renderScratchFrames * 2)
|
||||||
renderScratch.initialize(repeating: 0, count: 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the VPIO engine for an active mic stream. The caller must have already enabled
|
/// The engine stopped itself because its configuration no longer matches the active AVAudio
|
||||||
/// external playback on the core (`client.setExternalPlayback(true)` + `audioRestart()`) and
|
/// route (this fires after a route change that the route-change handler can't always outrun).
|
||||||
/// started the MIC stream with `externalFeed: true`.
|
/// Dispatch to main and call the unified `recoverAudio()` — it's intent-gated on
|
||||||
func start(client: VoiceCatClient, micStreamId: UInt32, captureChannels: UInt32) {
|
/// `isConnected`, idempotent, and no-ops if the engine is already running (the common case
|
||||||
guard !isRunning else { return }
|
/// 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
|
self.client = client
|
||||||
self.micStreamId = micStreamId
|
guard !isConnected else { return }
|
||||||
|
isConnected = true
|
||||||
ring.reset()
|
ring.reset()
|
||||||
|
|
||||||
// Enable the voice-processing I/O unit (AEC/NS/AGC) on the shared input+output unit.
|
// Wire the core's mixed-output sink into the ring (C function pointer, no captures). Stays
|
||||||
do {
|
// registered for the whole connection; the ring is drained by the source-node render block.
|
||||||
try engine.inputNode.setVoiceProcessingEnabled(true)
|
let ringPtr = Unmanaged.passUnretained(self.ring).toOpaque()
|
||||||
} catch {
|
client.setMixedOutputSink({ user, pcm, spc, ch, _ in
|
||||||
logger.error("setVoiceProcessingEnabled failed: \(error.localizedDescription) — AEC unavailable")
|
guard let user, let pcm else { return }
|
||||||
|
let ring = Unmanaged<PCMRing>.fromOpaque(user).takeUnretainedValue()
|
||||||
|
ring.write(pcm, count: spc * Int(ch))
|
||||||
|
}, user: ringPtr)
|
||||||
|
|
||||||
|
rebuild()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Playback: source node pulls mixed PCM from the ring through the VPIO output. ──
|
/// 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 ring = self.ring
|
||||||
let scratch = self.renderScratch
|
let scratch = self.renderScratch
|
||||||
let scratchFrames = self.renderScratchFrames
|
let scratchFrames = self.renderScratchFrames
|
||||||
@@ -156,17 +356,11 @@ final class IOSVoiceProcessingEngine {
|
|||||||
let abl = UnsafeMutableAudioBufferListPointer(ablPtr)
|
let abl = UnsafeMutableAudioBufferListPointer(ablPtr)
|
||||||
let n = min(frames, scratchFrames)
|
let n = min(frames, scratchFrames)
|
||||||
let got = ring.read(into: scratch, count: n * 2) / 2 // interleaved stereo → frames
|
let got = ring.read(into: scratch, count: n * 2) / 2 // interleaved stereo → frames
|
||||||
// Deinterleave int16 → Float32 per channel; silence-fill any underrun tail.
|
|
||||||
let scale: Float = 1.0 / 32768.0
|
let scale: Float = 1.0 / 32768.0
|
||||||
for ch in 0..<abl.count {
|
for ch in 0..<abl.count {
|
||||||
guard let base = abl[ch].mData?.assumingMemoryBound(to: Float.self) else { continue }
|
guard let base = abl[ch].mData?.assumingMemoryBound(to: Float.self) else { continue }
|
||||||
for i in 0..<frames {
|
for i in 0..<frames {
|
||||||
if i < got {
|
base[i] = i < got ? Float(scratch[i * 2 + min(ch, 1)]) * scale : 0
|
||||||
let s = scratch[i * 2 + min(ch, 1)]
|
|
||||||
base[i] = Float(s) * scale
|
|
||||||
} else {
|
|
||||||
base[i] = 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return noErr
|
return noErr
|
||||||
@@ -174,29 +368,39 @@ final class IOSVoiceProcessingEngine {
|
|||||||
sourceNode = src
|
sourceNode = src
|
||||||
engine.attach(src)
|
engine.attach(src)
|
||||||
engine.connect(src, to: engine.mainMixerNode, format: outFormat)
|
engine.connect(src, to: engine.mainMixerNode, format: outFormat)
|
||||||
|
|
||||||
// ── Mic: tap the VPIO input node, convert to 48 kHz int16, feed the core. ──
|
|
||||||
let inFormat = engine.inputNode.outputFormat(forBus: 0)
|
|
||||||
let targetCh = max(1, min(2, captureChannels))
|
|
||||||
let target = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000,
|
|
||||||
channels: AVAudioChannelCount(targetCh), interleaved: true)
|
|
||||||
micTargetFormat = target
|
|
||||||
micConverter = (target != nil && inFormat.sampleRate > 0)
|
|
||||||
? AVAudioConverter(from: inFormat, to: target!) : nil
|
|
||||||
if micConverter == nil {
|
|
||||||
logger.error("mic converter unavailable (in=\(inFormat)) — mic will not transmit")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let c = client
|
/// Install the mic tap: convert the input node's native format to 48 kHz int16 (mono or
|
||||||
let sid = micStreamId
|
/// stereo per `captureChannels`) and write it to the pacing ring. The 20 ms feed pump
|
||||||
let converter = micConverter
|
/// (`startMicTimer`) releases steady 960-sample frames to `feedPcm` — see the mic-feed comment
|
||||||
let tgt = micTargetFormat
|
/// 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
|
engine.inputNode.installTap(onBus: 0, bufferSize: 960, format: inFormat) { buffer, _ in
|
||||||
guard let converter, let tgt else { return }
|
|
||||||
// Convert this tap buffer to 48 kHz int16. Output capacity scaled for any upsample.
|
// Convert this tap buffer to 48 kHz int16. Output capacity scaled for any upsample.
|
||||||
let ratio = tgt.sampleRate / buffer.format.sampleRate
|
let ratio = target.sampleRate / buffer.format.sampleRate
|
||||||
let outCap = AVAudioFrameCount(Double(buffer.frameLength) * ratio + 16)
|
let outCap = AVAudioFrameCount(Double(buffer.frameLength) * ratio + 16)
|
||||||
guard let outBuf = AVAudioPCMBuffer(pcmFormat: tgt, frameCapacity: outCap) else { return }
|
guard let outBuf = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outCap) else { return }
|
||||||
var fed = false
|
var fed = false
|
||||||
let status = converter.convert(to: outBuf, error: nil) { _, outStatus in
|
let status = converter.convert(to: outBuf, error: nil) { _, outStatus in
|
||||||
if fed { outStatus.pointee = .noDataNow; return nil }
|
if fed { outStatus.pointee = .noDataNow; return nil }
|
||||||
@@ -206,64 +410,66 @@ final class IOSVoiceProcessingEngine {
|
|||||||
}
|
}
|
||||||
guard status != .error, outBuf.frameLength > 0,
|
guard status != .error, outBuf.frameLength > 0,
|
||||||
let chData = outBuf.int16ChannelData else { return }
|
let chData = outBuf.int16ChannelData else { return }
|
||||||
let spc = Int(outBuf.frameLength)
|
// int16 interleaved → channelData[0] is the interleaved buffer. Write the converter's
|
||||||
// int16 interleaved → channelData[0] is the interleaved buffer for interleaved formats.
|
// variable-length output to the pacing ring; the 20 ms feed pump releases steady
|
||||||
c.feedPcm(streamId: sid, pcm: chData[0], samplesPerChannel: spc, channels: targetCh)
|
// 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)
|
||||||
|
|
||||||
// ── Wire the core's mixed-output sink into the ring (C function pointer, no captures). ──
|
|
||||||
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)
|
|
||||||
|
|
||||||
engine.prepare()
|
|
||||||
do {
|
|
||||||
try engine.start()
|
|
||||||
isRunning = true
|
|
||||||
// Diagnostics: capture the negotiated graph formats and the live output route so a
|
|
||||||
// silent-playback report can be triaged (format/rate mismatch vs. routing vs. the
|
|
||||||
// core not delivering PCM — see the ring stats logged in teardown()).
|
|
||||||
let outFmt = engine.outputNode.outputFormat(forBus: 0)
|
|
||||||
let mixFmt = engine.mainMixerNode.outputFormat(forBus: 0)
|
|
||||||
let route = AVAudioSession.sharedInstance().currentRoute.outputs
|
|
||||||
.map { "\($0.portName)[\($0.portType.rawValue)]" }.joined(separator: ", ")
|
|
||||||
logger.info("""
|
|
||||||
VPIO engine started — inFormat=\(inFormat), captureCh=\(targetCh), \
|
|
||||||
outputNode=\(outFmt), mainMixer=\(mixFmt), outputRoute=[\(route)]
|
|
||||||
""")
|
|
||||||
} catch {
|
|
||||||
logger.error("VPIO engine start failed: \(error.localizedDescription)")
|
|
||||||
teardown()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the VPIO engine. The caller is responsible for restoring the core's hardware playback
|
// MARK: - Mic feed pump (paces feedPcm at a steady 20 ms cadence)
|
||||||
/// afterwards (`client.setExternalPlayback(false)` + `audioRestart()`).
|
|
||||||
func stop() {
|
/// Start the 20 ms feed pump. After priming a small cushion (`pumpState.targetFrames`), it
|
||||||
guard isRunning else { return }
|
/// releases ONE 960-sample frame per tick from `micRing` to `feedPcm`, so the core (which sends
|
||||||
teardown()
|
/// synchronously per captured frame) emits packets at a steady 20 ms — the cadence its receivers
|
||||||
logger.info("VPIO engine stopped")
|
/// 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 teardown() {
|
private func stopMicTimer() {
|
||||||
client?.setMixedOutputSink(nil, user: nil)
|
micTimer?.cancel()
|
||||||
engine.inputNode.removeTap(onBus: 0)
|
micTimer = nil
|
||||||
if engine.isRunning { engine.stop() }
|
|
||||||
// Diagnostics (threads now quiesced): how much mixed PCM the core delivered into the ring
|
|
||||||
// vs. how much the render thread consumed. written==0 ⇒ the core never delivered (Bug 1
|
|
||||||
// core/lifecycle path); written>0 with no audible output ⇒ the AVAudioEngine output graph.
|
|
||||||
logger.info("VPIO ring stats — written=\(self.ring.debugTotalWritten) read=\(self.ring.debugTotalRead) samples")
|
|
||||||
try? engine.inputNode.setVoiceProcessingEnabled(false)
|
|
||||||
if let src = sourceNode {
|
|
||||||
engine.detach(src)
|
|
||||||
sourceNode = nil
|
|
||||||
}
|
|
||||||
micConverter = nil
|
|
||||||
micTargetFormat = nil
|
|
||||||
ring.reset()
|
|
||||||
isRunning = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchScreen</key>
|
||||||
<string>LaunchScreen</string>
|
<dict/>
|
||||||
<key>UIRequiresFullScreen</key>
|
<key>UIRequiresFullScreen</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ struct ActivityEntry: Identifiable {
|
|||||||
|
|
||||||
struct VoiceState {
|
struct VoiceState {
|
||||||
var micActive = false
|
var micActive = false
|
||||||
|
var voiceSubscribed = false
|
||||||
var selfMuted = false
|
var selfMuted = false
|
||||||
var selfDeafened = false
|
var selfDeafened = false
|
||||||
var serverMuted = false
|
var serverMuted = false
|
||||||
var serverDeafened = false
|
var serverDeafened = false
|
||||||
var inputMode: VoiceCatInputMode = .voiceActivation
|
var inputMode: VoiceCatInputMode = .voiceActivation
|
||||||
var vadThreshold: Float = 0.025
|
var vadThreshold: Float = 0.025
|
||||||
|
var inputGain: Float = 1.0
|
||||||
|
var inputNoiseReduction: Bool = false
|
||||||
var level: Float = 0.0
|
var level: Float = 0.0
|
||||||
var currentDeviceId: String?
|
var currentDeviceId: String?
|
||||||
var localStreamId: UInt32 = 0
|
var localStreamId: UInt32 = 0
|
||||||
@@ -51,6 +54,30 @@ final class SessionState {
|
|||||||
var accounts: [Account] = []
|
var accounts: [Account] = []
|
||||||
var devices: [Device] = []
|
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
|
/// 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.
|
/// and feeds the SCREEN_AUDIO stream this session owns. See BroadcastAudioPump.
|
||||||
private let broadcastPump = BroadcastAudioPump()
|
private let broadcastPump = BroadcastAudioPump()
|
||||||
@@ -59,7 +86,7 @@ final class SessionState {
|
|||||||
self.client = client
|
self.client = client
|
||||||
self.selfUserId = selfUserId
|
self.selfUserId = selfUserId
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
AudioSessionManager.shared.client = client
|
loadAndApplyVoiceSettings()
|
||||||
refreshChannels()
|
refreshChannels()
|
||||||
refreshUsers()
|
refreshUsers()
|
||||||
syncSelfChannel()
|
syncSelfChannel()
|
||||||
@@ -73,16 +100,10 @@ final class SessionState {
|
|||||||
broadcastPump.onBroadcastStarted = { [weak self] in self?.startScreenShare() }
|
broadcastPump.onBroadcastStarted = { [weak self] in self?.startScreenShare() }
|
||||||
broadcastPump.onBroadcastFinished = { [weak self] in self?.stopScreenShare() }
|
broadcastPump.onBroadcastFinished = { [weak self] in self?.stopScreenShare() }
|
||||||
broadcastPump.start()
|
broadcastPump.start()
|
||||||
// When IOSAudioRouter changes the audio config, restart the voice path if needed so the
|
|
||||||
// native VPIO engine (AEC/NS/AGC) engages or disengages to match the new preset/route.
|
|
||||||
AudioSessionManager.shared.reconcileVoicePath = { [weak self] in self?.reconcileVoicePath() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
broadcastPump.stop()
|
broadcastPump.stop()
|
||||||
MainActor.assumeIsolated {
|
|
||||||
AudioSessionManager.shared.client = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Event dispatch
|
// MARK: - Event dispatch
|
||||||
@@ -92,7 +113,22 @@ final class SessionState {
|
|||||||
case .channelList:
|
case .channelList:
|
||||||
refreshChannels()
|
refreshChannels()
|
||||||
syncSelfChannel()
|
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()
|
refreshUsers()
|
||||||
syncSelfChannel()
|
syncSelfChannel()
|
||||||
case .userUpdated:
|
case .userUpdated:
|
||||||
@@ -103,13 +139,27 @@ final class SessionState {
|
|||||||
}
|
}
|
||||||
case .textMessage:
|
case .textMessage:
|
||||||
let sender = users.first(where: { $0.id == ev.userId })?.nickname ?? "Unknown"
|
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(
|
messages.append(ChatMessage(
|
||||||
timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000),
|
timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000),
|
||||||
senderName: sender,
|
senderName: sender,
|
||||||
text: ev.text ?? "",
|
text: body,
|
||||||
scope: ev.textScope))
|
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:
|
case .talkState:
|
||||||
let talking = ev.u32a != 0
|
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)"
|
let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)"
|
||||||
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
|
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
|
||||||
case .streamStarted:
|
case .streamStarted:
|
||||||
@@ -126,8 +176,6 @@ final class SessionState {
|
|||||||
addActivity("Sharing screen audio (\(channels == 2 ? "stereo" : "mono"))")
|
addActivity("Sharing screen audio (\(channels == 2 ? "stereo" : "mono"))")
|
||||||
break
|
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 {
|
if ev.userId != selfUserId {
|
||||||
do {
|
do {
|
||||||
try AudioSessionManager.shared.ensureSessionActive()
|
try AudioSessionManager.shared.ensureSessionActive()
|
||||||
@@ -138,14 +186,48 @@ final class SessionState {
|
|||||||
AudioSessionManager.shared.logSessionState("stream started (user \(ev.userId))")
|
AudioSessionManager.shared.logSessionState("stream started (user \(ev.userId))")
|
||||||
addActivity("Stream started (user \(ev.userId))")
|
addActivity("Stream started (user \(ev.userId))")
|
||||||
case .streamStopped:
|
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))")
|
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:
|
case .joinResult:
|
||||||
if ev.result == .ok {
|
if ev.result == .ok {
|
||||||
currentChannelId = ev.channelId
|
currentChannelId = ev.channelId
|
||||||
addActivity("Joined channel")
|
addActivity("Joined channel")
|
||||||
refreshUsers()
|
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 {
|
} else {
|
||||||
addActivity("Join failed: \(ev.result.description)")
|
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:
|
case .error:
|
||||||
addActivity("Error: \(ev.text ?? ev.result.description)")
|
addActivity("Error: \(ev.text ?? ev.result.description)")
|
||||||
@@ -155,6 +237,16 @@ final class SessionState {
|
|||||||
}
|
}
|
||||||
case .accountList:
|
case .accountList:
|
||||||
accounts = client.listAccounts()
|
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:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -168,17 +260,18 @@ final class SessionState {
|
|||||||
// MARK: - Self-channel / server-mute sync
|
// MARK: - Self-channel / server-mute sync
|
||||||
|
|
||||||
/// Sync currentChannelId from the self user's channelId in the user list. Mirrors macOS
|
/// 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
|
/// MainWindowController's bootstrap/event-handling sync. The server auto-places every
|
||||||
/// the Lobby (channel 1) on connect, but without this sync currentChannelId stays 0 and
|
/// authed user into the Lobby (channel 1) on connect, but without this sync
|
||||||
/// the mic button (gated on currentChannelId == 0) stays permanently dimmed.
|
/// currentChannelId stays 0 and the mic button (gated on currentChannelId == 0) stays
|
||||||
|
/// permanently dimmed.
|
||||||
private func syncSelfChannel() {
|
private func syncSelfChannel() {
|
||||||
if let me = users.first(where: { $0.id == selfUserId }) {
|
if let me = users.first(where: { $0.id == selfUserId }) {
|
||||||
currentChannelId = me.channelId
|
currentChannelId = me.channelId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply server-side mute/deafen state — mirrors macOS MainWindowController.swift:693-700.
|
/// Apply server-side mute/deafen state — mirrors macOS MainWindowController's handling
|
||||||
/// iOS was previously ignoring server mute/deafen entirely.
|
/// of UserEvent.UPDATED for the self user.
|
||||||
private func applyServerMuteState(muted: Bool, deafened: Bool) {
|
private func applyServerMuteState(muted: Bool, deafened: Bool) {
|
||||||
if muted && !voiceState.serverMuted { addActivity("You have been server-muted") }
|
if muted && !voiceState.serverMuted { addActivity("You have been server-muted") }
|
||||||
if deafened && !voiceState.serverDeafened { addActivity("You have been server-deafened") }
|
if deafened && !voiceState.serverDeafened { addActivity("You have been server-deafened") }
|
||||||
@@ -205,12 +298,15 @@ final class SessionState {
|
|||||||
currentChannelId = 0
|
currentChannelId = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func startMicStream() {
|
func joinVoice() {
|
||||||
AVAudioApplication.requestRecordPermission { [weak self] granted in
|
AVAudioApplication.requestRecordPermission { [weak self] granted in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if granted {
|
if granted {
|
||||||
self.doStartMicStream()
|
let result = self.client.joinVoice()
|
||||||
|
if result != .ok {
|
||||||
|
self.addActivity("Failed to join voice: \(result.description)")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.addActivity("Microphone permission denied — grant in Settings > Privacy > Microphone")
|
self.addActivity("Microphone permission denied — grant in Settings > Privacy > Microphone")
|
||||||
}
|
}
|
||||||
@@ -226,94 +322,72 @@ final class SessionState {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// VPIO path: on the AEC presets, the native AVAudioEngine does AEC/NS/AGC and the core
|
|
||||||
// runs in external mode (no hardware mic/playback). The mic stream is started with
|
|
||||||
// externalFeed so the core skips the hardware capture device; setExternalPlayback makes
|
|
||||||
// it skip the hardware playback device and deliver the mix to IOSVoiceProcessingEngine.
|
|
||||||
//
|
|
||||||
// ORDER MATTERS: set the external-playback flag now, but defer audioRestart() until
|
|
||||||
// AFTER startStream (below) so the MIC LocalStream — which carries external_feed=true —
|
|
||||||
// already exists when ensure_audio_running() derives external_capture. Restarting before
|
|
||||||
// the stream exists makes the core reopen a hardware capture device that is never dropped
|
|
||||||
// (the announce-result restart early-returns because the engine is already running); that
|
|
||||||
// lingering miniaudio capture unit then fights the AVAudioEngine VPIO unit on the same
|
|
||||||
// .voiceChat session and silences VPIO playback.
|
|
||||||
let useVPIO = IOSAudioRouter.shared.currentConfigUsesVoiceProcessing
|
|
||||||
client.setExternalPlayback(useVPIO)
|
|
||||||
|
|
||||||
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic",
|
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic",
|
||||||
externalFeed: useVPIO)
|
externalFeed: true)
|
||||||
let (result, streamId) = client.startStream(desc)
|
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.micActive = true
|
||||||
voiceState.localStreamId = streamId
|
voiceState.localStreamId = streamId
|
||||||
// Publish the active mic stream ID so IOSAudioRouter can reset the core's capture
|
EventFeedback.shared.play(.voiceOn)
|
||||||
// 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.
|
|
||||||
let channels = IOSAudioRouter.shared.captureChannels.channelCount
|
let channels = IOSAudioRouter.shared.captureChannels.channelCount
|
||||||
if channels != 1 {
|
if channels != 1 {
|
||||||
client.setCaptureChannels(streamId: streamId, channels: channels)
|
client.setCaptureChannels(streamId: streamId, channels: channels)
|
||||||
}
|
}
|
||||||
if useVPIO {
|
IOSAudioEngine.shared.startMic(streamId: streamId, channels: channels)
|
||||||
// The external-feed MIC stream now exists, so restart the core into full
|
|
||||||
// external mode (no hardware capture/playback, mixer-timer only) — mic and
|
|
||||||
// speaker are owned entirely by the VPIO engine, which we start right after.
|
|
||||||
client.audioRestart()
|
|
||||||
IOSVoiceProcessingEngine.shared.start(
|
|
||||||
client: client, micStreamId: streamId, captureChannels: 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 {
|
} else {
|
||||||
addActivity("Failed to start mic: \(result.description)")
|
// No prior channel — go straight to the voice/mute restore. (voiceSubscribed with
|
||||||
if useVPIO { // revert external-playback mode so remote audio still plays
|
// channelId == 0 is contradictory; `completeRestore` further guards on
|
||||||
client.setExternalPlayback(false)
|
// currentChannelId != 0 before subscribing to voice.)
|
||||||
client.audioRestart()
|
completeRestore()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopMicStream() {
|
/// Finish the restore after the channel is in place (or there was no channel to restore):
|
||||||
// Tear down the VPIO engine first (removes the mic tap + unregisters the mixed sink),
|
/// re-subscribe to voice if the user was transmitting, and re-apply the local mute/deafen
|
||||||
// then stop the mic stream, then restore the core's hardware playback for any remaining
|
/// state. Safe to call once per `pendingRestore`; clears it.
|
||||||
// remote audio. Order matters: the mic stream must be gone before audioRestart so the
|
private func completeRestore() {
|
||||||
// core opens a normal playback device (and no capture device — there's no mic stream).
|
guard let r = pendingRestore else { return }
|
||||||
let wasVPIO = IOSVoiceProcessingEngine.shared.isRunning
|
if r.voiceSubscribed && currentChannelId != 0 {
|
||||||
if wasVPIO {
|
joinVoice()
|
||||||
IOSVoiceProcessingEngine.shared.stop()
|
|
||||||
}
|
}
|
||||||
if voiceState.localStreamId != 0 {
|
setMute(r.micMuted, deafened: r.deafened)
|
||||||
client.stopStream(voiceState.localStreamId)
|
addActivity("Restored to channel \(currentChannelId)"
|
||||||
voiceState.localStreamId = 0
|
+ (r.voiceSubscribed ? " with voice" : ""))
|
||||||
AudioSessionManager.shared.activeMicStreamId = nil
|
pendingRestore = nil
|
||||||
}
|
|
||||||
if wasVPIO {
|
|
||||||
client.setExternalPlayback(false)
|
|
||||||
client.audioRestart() // reopen hardware playback (no mic stream → no hw capture)
|
|
||||||
}
|
|
||||||
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).
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restart the voice path when the audio config changes mid-call (driven by IOSAudioRouter).
|
|
||||||
/// If VPIO is involved on either the current or desired side, restart the mic so the native
|
|
||||||
/// voice-processing engine engages/disengages and re-binds to the new route. Pure miniaudio
|
|
||||||
/// config tweaks need no restart — the core's own audioRestart (already issued) handles them.
|
|
||||||
private func reconcileVoicePath() {
|
|
||||||
guard voiceState.micActive else { return }
|
|
||||||
let want = IOSAudioRouter.shared.currentConfigUsesVoiceProcessing
|
|
||||||
let have = IOSVoiceProcessingEngine.shared.isRunning
|
|
||||||
guard want || have else { return }
|
|
||||||
stopMicStream()
|
|
||||||
doStartMicStream()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Screen audio share
|
// MARK: - Screen audio share
|
||||||
@@ -360,15 +434,65 @@ final class SessionState {
|
|||||||
func setInputMode(_ mode: VoiceCatInputMode) {
|
func setInputMode(_ mode: VoiceCatInputMode) {
|
||||||
client.setInputMode(mode)
|
client.setInputMode(mode)
|
||||||
voiceState.inputMode = mode
|
voiceState.inputMode = mode
|
||||||
|
UserDefaults.standard.set(Int(mode.rawValue), forKey: DefaultsKey.inputMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setVadThreshold(_ threshold: Float) {
|
func setVadThreshold(_ threshold: Float) {
|
||||||
client.setVadThreshold(threshold)
|
client.setVadThreshold(threshold)
|
||||||
voiceState.vadThreshold = 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) {
|
func setPushToTalk(_ active: Bool) {
|
||||||
client.setPushToTalk(active)
|
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
|
// MARK: - Text
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ struct ChannelEditView: View {
|
|||||||
@State private var maxUsers = "0"
|
@State private var maxUsers = "0"
|
||||||
@State private var sortOrder = "0"
|
@State private var sortOrder = "0"
|
||||||
|
|
||||||
// Audio (Opus). Note: the channel list does not carry the current audio config, so when
|
// Audio (Opus) — populated from the channel's current config when editing.
|
||||||
// editing an existing channel these start from the codec defaults (same as macOS/Windows).
|
|
||||||
@State private var stereo = false
|
@State private var stereo = false
|
||||||
@State private var bitrate = "64000"
|
@State private var bitrate = "64000"
|
||||||
@State private var sampleRate = "48000"
|
@State private var sampleRate = "48000"
|
||||||
@@ -122,6 +121,17 @@ struct ChannelEditView: View {
|
|||||||
parentId = ch.parentId
|
parentId = ch.parentId
|
||||||
passwordProtected = ch.passwordProtected
|
passwordProtected = ch.passwordProtected
|
||||||
maxUsers = "\(ch.maxUsers)"
|
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() {
|
private func save() {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ struct ChatView: View {
|
|||||||
private func sendMessage() {
|
private func sendMessage() {
|
||||||
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !text.isEmpty else { return }
|
guard !text.isEmpty else { return }
|
||||||
session.sendText(text, scope: .channel)
|
session.sendText(text, scope: .channel, targetId: session.currentChannelId)
|
||||||
composeText = ""
|
composeText = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ struct PerUserTuningView: View {
|
|||||||
Section("Volume") {
|
Section("Volume") {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Gain")
|
Text("Gain")
|
||||||
Slider(value: $gain, in: 0...2, step: 0.05) { _ in
|
Slider(value: $gain, in: 0...4, step: 0.05) { _ in
|
||||||
applyToAllStreams()
|
applyToAllStreams()
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Volume gain for \(user.nickname)")
|
.accessibilityLabel("Volume gain for \(user.nickname)")
|
||||||
@@ -48,7 +48,6 @@ struct PerUserTuningView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Load from first stream if available
|
|
||||||
let streams = streamsForUser
|
let streams = streamsForUser
|
||||||
if let first = streams.first {
|
if let first = streams.first {
|
||||||
let (_, state) = session.client.getRemoteStream(userId: user.id, streamId: first.id)
|
let (_, state) = session.client.getRemoteStream(userId: user.id, streamId: first.id)
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ struct SettingsView: View {
|
|||||||
@StateObject private var router = IOSAudioRouter.shared
|
@StateObject private var router = IOSAudioRouter.shared
|
||||||
@State private var showAdvanced = false
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
@@ -30,9 +38,9 @@ struct SettingsView: View {
|
|||||||
.accessibilityLabel("Speaker output")
|
.accessibilityLabel("Speaker output")
|
||||||
.accessibilityHint("Routes audio to the speaker instead of the earpiece when no headphones are connected.")
|
.accessibilityHint("Routes audio to the speaker instead of the earpiece when no headphones are connected.")
|
||||||
|
|
||||||
// Surface the voice-processing state. On the AEC presets the native iOS
|
// Surface the voice-processing state. On Voice Chat the native iOS
|
||||||
// Voice-Processing unit (VPIO) does echo cancellation, noise suppression and
|
// Voice-Processing unit (VPIO) does echo cancellation, noise suppression and
|
||||||
// automatic gain control; the other presets (stereo/studio/A2DP) can't use it.
|
// automatic gain control; the stereo / mono-mic / A2DP configs can't use it.
|
||||||
if router.currentConfigUsesVoiceProcessing {
|
if router.currentConfigUsesVoiceProcessing {
|
||||||
Label("Echo cancellation & noise suppression on (iOS voice processing)",
|
Label("Echo cancellation & noise suppression on (iOS voice processing)",
|
||||||
systemImage: "waveform.badge.mic")
|
systemImage: "waveform.badge.mic")
|
||||||
@@ -40,18 +48,11 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.accessibilityLabel("Echo cancellation and noise suppression are on")
|
.accessibilityLabel("Echo cancellation and noise suppression are on")
|
||||||
} else {
|
} else {
|
||||||
Label("No echo cancellation in this preset (stereo / studio / A2DP)",
|
Label("No echo cancellation in this configuration (stereo / A2DP / off)",
|
||||||
systemImage: "waveform.slash")
|
systemImage: "waveform.slash")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.accessibilityLabel("Echo cancellation is off in this preset")
|
.accessibilityLabel("Echo cancellation is off in this configuration")
|
||||||
}
|
|
||||||
|
|
||||||
if !router.hasBluetoothDevice && !router.hasWiredHeadset {
|
|
||||||
Text("Connect Bluetooth headphones or a wired headset for more presets.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.accessibilityLabel("No external audio device connected")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +120,27 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel("Microphone processing mode")
|
.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 {
|
if router.showsRawModeSpeakerWarning {
|
||||||
Label(
|
Label(
|
||||||
"Raw mode on speaker — echo risk (no AEC)",
|
"Raw mode on speaker — echo risk (no AEC)",
|
||||||
@@ -215,6 +237,48 @@ struct SettingsView: View {
|
|||||||
.accessibilityLabel("Voice activation threshold")
|
.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
|
// MARK: - Admin
|
||||||
@@ -230,7 +294,7 @@ struct SettingsView: View {
|
|||||||
// MARK: - Server
|
// MARK: - Server
|
||||||
Section("Server") {
|
Section("Server") {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
session.stopMicStream()
|
session.leaveVoice()
|
||||||
appState.disconnect()
|
appState.disconnect()
|
||||||
} label: {
|
} label: {
|
||||||
Label("Disconnect", systemImage: "phone.down")
|
Label("Disconnect", systemImage: "phone.down")
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ struct UserRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
UserRowView(user: user, isSelf: user.id == session.selfUserId)
|
UserRowView(user: user, isSelf: user.id == session.selfUserId)
|
||||||
.contextMenu { contextMenu }
|
.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
|
.sheet(item: $activeSheet) { sheet in
|
||||||
switch sheet {
|
switch sheet {
|
||||||
case .tuning: PerUserTuningView(user: user, session: session)
|
case .tuning: PerUserTuningView(user: user, session: session)
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ struct VoiceControlsView: View {
|
|||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
if session.voiceState.micActive {
|
if session.voiceState.micActive {
|
||||||
session.stopMicStream()
|
session.leaveVoice()
|
||||||
} else {
|
} else {
|
||||||
session.startMicStream()
|
session.joinVoice()
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(session.voiceState.micActive ? "Leave Voice" : "Join Voice")
|
Text(session.voiceState.micActive ? "Leave Voice" : "Join Voice")
|
||||||
@@ -28,7 +28,7 @@ struct VoiceControlsView: View {
|
|||||||
.foregroundStyle(session.voiceState.micActive ? .green : .accentColor)
|
.foregroundStyle(session.voiceState.micActive ? .green : .accentColor)
|
||||||
}
|
}
|
||||||
.disabled(session.currentChannelId == 0)
|
.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
|
// Level meter
|
||||||
@@ -81,7 +81,7 @@ struct VoiceControlsView: View {
|
|||||||
|
|
||||||
// Disconnect
|
// Disconnect
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
session.stopMicStream()
|
session.leaveVoice()
|
||||||
session.client.disconnect()
|
session.client.disconnect()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "phone.down.fill")
|
Image(systemName: "phone.down.fill")
|
||||||
@@ -115,13 +115,13 @@ private struct PTTButton: View {
|
|||||||
.updating($isPressing) { _, state, _ in state = true }
|
.updating($isPressing) { _, state, _ in state = true }
|
||||||
.onChanged { _ in
|
.onChanged { _ in
|
||||||
if !isPressing { return }
|
if !isPressing { return }
|
||||||
if !session.voiceState.micActive { session.startMicStream() }
|
if !session.voiceState.micActive { session.joinVoice() }
|
||||||
session.setPushToTalk(true)
|
session.setPushToTalk(true)
|
||||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
session.setPushToTalk(false)
|
session.setPushToTalk(false)
|
||||||
session.stopMicStream()
|
session.leaveVoice()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.accessibilityLabel("Push to talk, hold to transmit")
|
.accessibilityLabel("Push to talk, hold to transmit")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.cat.voice.VoiceCat</string>
|
<string>group.me.iamtalon.voicecat</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
AAAA00000000000000000048 /* UserPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000047 /* UserPickerSheet.swift */; };
|
AAAA00000000000000000048 /* UserPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000047 /* UserPickerSheet.swift */; };
|
||||||
AAAA0000000000000000004A /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000049 /* SettingsWindowController.swift */; };
|
AAAA0000000000000000004A /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000049 /* SettingsWindowController.swift */; };
|
||||||
AAAA0000000000000000004C /* ScreenAudioCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000004B /* ScreenAudioCapture.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 */; };
|
AAAA0000000000000000004E /* ScreenSharePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000004F /* ScreenSharePickerSheet.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
AAAA00000000000000000019 /* ConnectWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectWindowController.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
AAAA0000000000000000001D /* PasswordPromptSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptSheet.swift; sourceTree = "<group>"; };
|
||||||
@@ -104,6 +106,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
AAAA0000000000000000004B /* ScreenAudioCapture.swift */,
|
AAAA0000000000000000004B /* ScreenAudioCapture.swift */,
|
||||||
|
AAAA00000000000000000050 /* InputDeviceCapture.swift */,
|
||||||
);
|
);
|
||||||
path = Audio;
|
path = Audio;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -247,6 +250,7 @@
|
|||||||
AAAA0000000000000000004E /* ScreenSharePickerSheet.swift in Sources */,
|
AAAA0000000000000000004E /* ScreenSharePickerSheet.swift in Sources */,
|
||||||
AAAA0000000000000000004A /* SettingsWindowController.swift in Sources */,
|
AAAA0000000000000000004A /* SettingsWindowController.swift in Sources */,
|
||||||
AAAA0000000000000000004C /* ScreenAudioCapture.swift in Sources */,
|
AAAA0000000000000000004C /* ScreenAudioCapture.swift in Sources */,
|
||||||
|
AAAA00000000000000000051 /* InputDeviceCapture.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
229
clients/apple/macOS/VoiceCatMac/Audio/InputDeviceCapture.swift
Normal file
229
clients/apple/macOS/VoiceCatMac/Audio/InputDeviceCapture.swift
Normal file
@@ -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,14 +1,11 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import ScreenCaptureKit
|
import ScreenCaptureKit
|
||||||
|
|
||||||
// Which apps' audio the SCREEN_AUDIO stream captures. ScreenCaptureKit filters audio at the
|
// ScreenCaptureKit filters audio by application bundle identifier.
|
||||||
// *application* level (not per-window), so the selection is expressed as bundle IDs. The
|
|
||||||
// picker UI (ScreenSharePickerSheet) produces a `ScreenAudioSelection`; `start()` turns it
|
|
||||||
// into the matching `SCContentFilter`.
|
|
||||||
enum ScreenAudioScope: Equatable {
|
enum ScreenAudioScope: Equatable {
|
||||||
case entireDesktop // whole display — the original behaviour
|
case entireDesktop
|
||||||
case onlyApps([String]) // capture only these bundle IDs
|
case onlyApps([String])
|
||||||
case allExcept([String]) // capture everything except these bundle IDs
|
case allExcept([String])
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ScreenAudioSelection: Equatable {
|
struct ScreenAudioSelection: Equatable {
|
||||||
@@ -20,20 +17,8 @@ struct ScreenAudioSelection: Equatable {
|
|||||||
static let `default` = ScreenAudioSelection()
|
static let `default` = ScreenAudioSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScreenAudioCapture — macOS system/desktop audio capture for the SCREEN_AUDIO stream.
|
// 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.
|
||||||
// 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.
|
|
||||||
final class ScreenAudioCapture: NSObject, SCStreamOutput, SCStreamDelegate {
|
final class ScreenAudioCapture: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||||
|
|
||||||
/// Receives a full 20 ms frame: (interleaved int16 PCM, samplesPerChannel = 960, channels).
|
/// Receives a full 20 ms frame: (interleaved int16 PCM, samplesPerChannel = 960, channels).
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ final class PerUserTuningSheet: NSViewController {
|
|||||||
let muted = state?.muted ?? false
|
let muted = state?.muted ?? false
|
||||||
let nr = state?.noiseReduction ?? 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.tag = Int(stream.id)
|
||||||
gainSlider.numberOfTickMarks = 0
|
gainSlider.numberOfTickMarks = 0
|
||||||
gainSlider.setAccessibilityLabel("Volume for \(stream.label): \(Int(gain * 100)) percent")
|
gainSlider.setAccessibilityLabel("Volume for \(stream.label): \(Int(gain * 100)) percent")
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
private func buildUI() {
|
private func buildUI() {
|
||||||
guard let contentView = window?.contentView else { return }
|
guard let contentView = window?.contentView else { return }
|
||||||
|
|
||||||
// Server list
|
|
||||||
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("server"))
|
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("server"))
|
||||||
col.title = "Saved Servers"
|
col.title = "Saved Servers"
|
||||||
serverTableView.addTableColumn(col)
|
serverTableView.addTableColumn(col)
|
||||||
@@ -61,7 +60,6 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
serverScrollView.translatesAutoresizingMaskIntoConstraints = false
|
serverScrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentView.addSubview(serverScrollView)
|
contentView.addSubview(serverScrollView)
|
||||||
|
|
||||||
// Buttons row
|
|
||||||
configureButton(addButton, title: "Add…", action: #selector(addClicked))
|
configureButton(addButton, title: "Add…", action: #selector(addClicked))
|
||||||
configureButton(editButton, title: "Edit…", action: #selector(editClicked))
|
configureButton(editButton, title: "Edit…", action: #selector(editClicked))
|
||||||
configureButton(removeButton, title: "Remove", action: #selector(removeClicked))
|
configureButton(removeButton, title: "Remove", action: #selector(removeClicked))
|
||||||
@@ -72,13 +70,11 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
buttonStack.translatesAutoresizingMaskIntoConstraints = false
|
buttonStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentView.addSubview(buttonStack)
|
contentView.addSubview(buttonStack)
|
||||||
|
|
||||||
// Status
|
|
||||||
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
statusLabel.textColor = .secondaryLabelColor
|
statusLabel.textColor = .secondaryLabelColor
|
||||||
statusLabel.setAccessibilityLabel("Connection status")
|
statusLabel.setAccessibilityLabel("Connection status")
|
||||||
contentView.addSubview(statusLabel)
|
contentView.addSubview(statusLabel)
|
||||||
|
|
||||||
// Connect button
|
|
||||||
connectButton.title = "Connect"
|
connectButton.title = "Connect"
|
||||||
connectButton.bezelStyle = .rounded
|
connectButton.bezelStyle = .rounded
|
||||||
connectButton.keyEquivalent = "\r"
|
connectButton.keyEquivalent = "\r"
|
||||||
@@ -376,7 +372,3 @@ extension ConnectWindowController: NSTableViewDataSource, NSTableViewDelegate {
|
|||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,15 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
internal var micStreamId: UInt32 = 0
|
internal var micStreamId: UInt32 = 0
|
||||||
private var screenStreamId: UInt32 = 0
|
private var screenStreamId: UInt32 = 0
|
||||||
private var screenCapture: ScreenAudioCapture?
|
private var screenCapture: ScreenAudioCapture?
|
||||||
|
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.
|
// Last app/exclusion choice from the share picker; reused as the default next time.
|
||||||
private var screenAudioSelection: ScreenAudioSelection = .default
|
private var screenAudioSelection: ScreenAudioSelection = .default
|
||||||
internal var pttKeyCode: UInt16 = 0x60 // F8
|
internal var pttKeyCode: UInt16 = 0x60 { // F8
|
||||||
|
didSet { UserDefaults.standard.set(Int(pttKeyCode), forKey: AudioDefaults.pttKeyCode) }
|
||||||
|
}
|
||||||
private var pttMonitor: Any?
|
private var pttMonitor: Any?
|
||||||
|
private var pttEngaged = false // guards the PTT cue against key-repeat
|
||||||
private var serverMuted = false
|
private var serverMuted = false
|
||||||
private var serverDeafened = false
|
private var serverDeafened = false
|
||||||
private var channelTree: [ChannelNode] = []
|
private var channelTree: [ChannelNode] = []
|
||||||
@@ -48,11 +53,56 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
private var settingsWindowController: SettingsWindowController?
|
private var settingsWindowController: SettingsWindowController?
|
||||||
|
|
||||||
// MARK: - Audio settings state (source of truth — read/written by 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
|
enum AudioDefaults {
|
||||||
internal var vadThresholdValue: Float = 0.05
|
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?
|
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
|
// MARK: - UI components
|
||||||
private let channelOutlineView = NSOutlineView()
|
private let channelOutlineView = NSOutlineView()
|
||||||
private let userTableView = NSTableView()
|
private let userTableView = NSTableView()
|
||||||
@@ -96,17 +146,46 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
super.init(window: window)
|
super.init(window: window)
|
||||||
window.delegate = self
|
window.delegate = self
|
||||||
|
|
||||||
|
loadPersistedAudioSettings()
|
||||||
buildUI()
|
buildUI()
|
||||||
buildToolbar()
|
buildToolbar()
|
||||||
wireEvents()
|
wireEvents()
|
||||||
bootstrap()
|
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() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
deinit {
|
deinit {}
|
||||||
NSLog("[VoiceCatMac] MainWindowController deinit — client and event handlers are gone")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UI construction
|
// MARK: - UI construction
|
||||||
|
|
||||||
@@ -343,7 +422,11 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
pttMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in
|
pttMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in
|
||||||
guard let self, self.selectedInputMode == .pushToTalk,
|
guard let self, self.selectedInputMode == .pushToTalk,
|
||||||
self.micStreamId != 0, event.keyCode == self.pttKeyCode else { return event }
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,10 +453,6 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
}
|
}
|
||||||
ownPermissions = client.getPermissions()
|
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)
|
// Apply initial output volume (default 80% — matches Windows client)
|
||||||
client.setOutputVolume(0.8)
|
client.setOutputVolume(0.8)
|
||||||
|
|
||||||
@@ -385,14 +464,13 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
buildMessagesMenu()
|
buildMessagesMenu()
|
||||||
buildAdminMenu()
|
buildAdminMenu()
|
||||||
addActivity("Connected to server as \(nickname)")
|
addActivity("Connected to server as \(nickname)")
|
||||||
|
EventFeedback.shared.play(.login)
|
||||||
|
EventFeedback.shared.speak("Connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Event handling
|
// MARK: - Event handling
|
||||||
|
|
||||||
private func handleEvent(_ event: VoiceCatEvent) {
|
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 {
|
switch event.type {
|
||||||
case .channelList:
|
case .channelList:
|
||||||
channels = client.listChannels()
|
channels = client.listChannels()
|
||||||
@@ -410,11 +488,14 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
isGuest: true,
|
isGuest: true,
|
||||||
channelId: event.channelId,
|
channelId: event.channelId,
|
||||||
selfMicMuted: false, selfDeafened: false,
|
selfMicMuted: false, selfDeafened: false,
|
||||||
serverMuted: false, serverDeafened: false)
|
serverMuted: false, serverDeafened: false,
|
||||||
|
voiceSubscribed: false)
|
||||||
users[event.userId] = u
|
users[event.userId] = u
|
||||||
refreshChannelTree(); refreshUserList()
|
refreshChannelTree(); refreshUserList()
|
||||||
if event.channelId == currentChannelId && event.userId != selfUserId {
|
if event.channelId == currentChannelId && event.userId != selfUserId {
|
||||||
addActivity("\(u.nickname) joined the channel")
|
addActivity("\(u.nickname) joined the channel")
|
||||||
|
EventFeedback.shared.play(.channelJoin)
|
||||||
|
EventFeedback.shared.speak("\(u.nickname) joined")
|
||||||
}
|
}
|
||||||
|
|
||||||
case .userLeft:
|
case .userLeft:
|
||||||
@@ -423,7 +504,11 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
users.removeValue(forKey: event.userId)
|
users.removeValue(forKey: event.userId)
|
||||||
talkingUsers.remove(event.userId)
|
talkingUsers.remove(event.userId)
|
||||||
refreshChannelTree(); refreshUserList()
|
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] {
|
if let pmWin = pmWindows[event.userId] {
|
||||||
pmWin.appendActivity("\(nick) disconnected from server")
|
pmWin.appendActivity("\(nick) disconnected from server")
|
||||||
}
|
}
|
||||||
@@ -447,7 +532,8 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
users[selfUserId] = User(id: self_.id, nickname: self_.nickname, isGuest: self_.isGuest,
|
users[selfUserId] = User(id: self_.id, nickname: self_.nickname, isGuest: self_.isGuest,
|
||||||
channelId: event.channelId,
|
channelId: event.channelId,
|
||||||
selfMicMuted: self_.selfMicMuted, selfDeafened: self_.selfDeafened,
|
selfMicMuted: self_.selfMicMuted, selfDeafened: self_.selfDeafened,
|
||||||
serverMuted: self_.serverMuted, serverDeafened: self_.serverDeafened)
|
serverMuted: self_.serverMuted, serverDeafened: self_.serverDeafened,
|
||||||
|
voiceSubscribed: self_.voiceSubscribed)
|
||||||
}
|
}
|
||||||
refreshChannelTree(); refreshUserList(); updateStatusLabel()
|
refreshChannelTree(); refreshUserList(); updateStatusLabel()
|
||||||
let name = channels.first(where: { $0.id == event.channelId })?.name ?? "Channel #\(event.channelId)"
|
let name = channels.first(where: { $0.id == event.channelId })?.name ?? "Channel #\(event.channelId)"
|
||||||
@@ -473,6 +559,9 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
let talking = event.u32a == 1
|
let talking = event.u32a == 1
|
||||||
if talking { talkingUsers.insert(event.userId) } else { talkingUsers.remove(event.userId) }
|
if talking { talkingUsers.insert(event.userId) } else { talkingUsers.remove(event.userId) }
|
||||||
refreshUserList()
|
refreshUserList()
|
||||||
|
if event.userId == selfUserId {
|
||||||
|
EventFeedback.shared.play(talking ? .vaStart : .vaStop)
|
||||||
|
}
|
||||||
if talking && event.userId != selfUserId,
|
if talking && event.userId != selfUserId,
|
||||||
let u = users[event.userId], u.channelId == currentChannelId {
|
let u = users[event.userId], u.channelId == currentChannelId {
|
||||||
addActivity("\(u.nickname) started talking")
|
addActivity("\(u.nickname) started talking")
|
||||||
@@ -493,10 +582,18 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
addActivity("\(u.nickname) started \(kindStr) stream")
|
addActivity("\(u.nickname) started \(kindStr) stream")
|
||||||
|
|
||||||
case .streamStopped:
|
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")
|
addActivity("\(u.nickname) stopped a stream")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case .voiceState:
|
||||||
|
handleVoiceState(event)
|
||||||
|
|
||||||
case .disconnected:
|
case .disconnected:
|
||||||
handleDisconnected(event)
|
handleDisconnected(event)
|
||||||
|
|
||||||
@@ -521,21 +618,28 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short)
|
time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short)
|
||||||
}
|
}
|
||||||
let sender = nickname(for: event.userId)
|
let sender = nickname(for: event.userId)
|
||||||
|
let isSelf = event.userId == selfUserId
|
||||||
|
let body = event.text ?? ""
|
||||||
|
|
||||||
if event.textScope == .private {
|
if event.textScope == .private {
|
||||||
// Route PMs to per-conversation windows. For our own outgoing PM, ev.channelId
|
// 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.
|
// 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)
|
let win = getOrOpenPmWindow(otherId)
|
||||||
win.appendMessage(time: time, isSelf: event.userId == selfUserId, sender: sender,
|
win.appendMessage(time: time, isSelf: isSelf, sender: sender, text: body)
|
||||||
text: event.text ?? "")
|
EventFeedback.shared.play(isSelf ? .pmSent : .pmRecv)
|
||||||
if event.userId != selfUserId {
|
if !isSelf {
|
||||||
addActivity("Private message from \(sender)")
|
addActivity("Private message from \(sender)")
|
||||||
|
EventFeedback.shared.speak("Private message from \(sender): \(body)")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let line = "[\(time)] \(sender): \(event.text ?? "")\n"
|
let line = "[\(time)] \(sender): \(body)\n"
|
||||||
logTextView.textStorage?.append(NSAttributedString(string: line))
|
logTextView.textStorage?.append(NSAttributedString(string: line))
|
||||||
logTextView.scrollToEndOfDocument(nil)
|
logTextView.scrollToEndOfDocument(nil)
|
||||||
|
EventFeedback.shared.play(isSelf ? .channelSent : .channelRecv)
|
||||||
|
if !isSelf {
|
||||||
|
EventFeedback.shared.speak("\(sender): \(body)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,11 +711,14 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
let msg = event.text.map { "Disconnected: \($0)" } ?? "Disconnected from server."
|
let msg = event.text.map { "Disconnected: \($0)" } ?? "Disconnected from server."
|
||||||
statusLabel.stringValue = msg
|
statusLabel.stringValue = msg
|
||||||
addActivity(msg)
|
addActivity(msg)
|
||||||
|
EventFeedback.shared.play(event.result == .ok ? .logout : .connectionLost)
|
||||||
|
EventFeedback.shared.speak(event.result == .ok ? "Disconnected" : "Connection lost")
|
||||||
channelTree = []; channelOutlineView.reloadData()
|
channelTree = []; channelOutlineView.reloadData()
|
||||||
displayedUsers = []; userTableView.reloadData()
|
displayedUsers = []; userTableView.reloadData()
|
||||||
users.removeAll(); talkingUsers.removeAll()
|
users.removeAll(); talkingUsers.removeAll()
|
||||||
stopScreenCapture()
|
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
|
composeField.isEnabled = false; sendButton.isEnabled = false
|
||||||
joinVoiceButton?.isEnabled = false
|
joinVoiceButton?.isEnabled = false
|
||||||
shareScreenButton?.isEnabled = false
|
shareScreenButton?.isEnabled = false
|
||||||
@@ -677,30 +784,127 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
|
|
||||||
@objc private func micToggleClicked() {
|
@objc private func micToggleClicked() {
|
||||||
if micStreamId == 0 {
|
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"))
|
let (result, streamId) = client.startStream(StreamDescriptor(kind: .mic, deviceId: nil, label: "Microphone"))
|
||||||
if result == .ok {
|
if result == .ok {
|
||||||
micStreamId = streamId
|
micStreamId = streamId
|
||||||
if let devId = selectedInputDeviceId {
|
if let devId = selectedInputDeviceId {
|
||||||
client.setInputDevice(streamId: streamId, deviceId: devId)
|
client.setInputDevice(streamId: streamId, deviceId: devId)
|
||||||
}
|
}
|
||||||
|
client.setCaptureChannels(streamId: streamId, channels: stereoMic ? 2 : 1)
|
||||||
client.setInputMode(selectedInputMode)
|
client.setInputMode(selectedInputMode)
|
||||||
if selectedInputMode == .voiceActivation {
|
if selectedInputMode == .voiceActivation {
|
||||||
client.setVadThreshold(vadThresholdValue)
|
client.setVadThreshold(vadThresholdValue)
|
||||||
}
|
}
|
||||||
|
client.setInputGain(inputGain)
|
||||||
|
client.setInputNoiseReduction(inputNoiseReduction)
|
||||||
setVoiceJoinedState(true)
|
setVoiceJoinedState(true)
|
||||||
addActivity("Joined voice — microphone active")
|
addActivity("Joined voice — microphone active")
|
||||||
|
EventFeedback.shared.play(.voiceOn)
|
||||||
NSAccessibility.post(element: logTextView, notification: .announcementRequested,
|
NSAccessibility.post(element: logTextView, notification: .announcementRequested,
|
||||||
userInfo: [.announcement: "Joined voice", .priority: NSAccessibilityPriorityLevel.medium])
|
userInfo: [.announcement: "Joined voice", .priority: NSAccessibilityPriorityLevel.medium])
|
||||||
|
startAuxStream()
|
||||||
} else {
|
} else {
|
||||||
addActivity("Failed to start microphone: \(result)")
|
addActivity("Failed to start microphone: \(result)")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
client.setPushToTalk(false)
|
|
||||||
client.stopStream(micStreamId)
|
|
||||||
micStreamId = 0
|
micStreamId = 0
|
||||||
settingsWindowController?.resetLevel()
|
settingsWindowController?.resetLevel()
|
||||||
setVoiceJoinedState(false)
|
setVoiceJoinedState(false)
|
||||||
addActivity("Left voice")
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,6 +944,15 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
/// Announce the SCREEN_AUDIO stream with the chosen selection in hand. ScreenCaptureKit
|
||||||
/// capture starts once the server's StreamAnnounceResult lands (the .streamStarted event),
|
/// capture starts once the server's StreamAnnounceResult lands (the .streamStarted event),
|
||||||
/// when the effective audio config — and thus the channel count — is known. See
|
/// when the effective audio config — and thus the channel count — is known. See
|
||||||
@@ -845,7 +1058,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
composeField.stringValue = ""
|
composeField.stringValue = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - M5: Moderation helpers
|
// MARK: - Moderation helpers
|
||||||
|
|
||||||
private func moveUser(_ user: User) {
|
private func moveUser(_ user: User) {
|
||||||
let sheet = MoveUserSheet(channels: channels, currentChannelId: user.channelId)
|
let sheet = MoveUserSheet(channels: channels, currentChannelId: user.channelId)
|
||||||
@@ -1019,7 +1232,6 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
presentSheet(sheet)
|
presentSheet(sheet)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a PM window from the user context menu.
|
|
||||||
private func openPmWindow(_ user: User) {
|
private func openPmWindow(_ user: User) {
|
||||||
getOrOpenPmWindow(user.id)
|
getOrOpenPmWindow(user.id)
|
||||||
}
|
}
|
||||||
@@ -1066,17 +1278,13 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
func windowWillClose(_ notification: Notification) {
|
func windowWillClose(_ notification: Notification) {
|
||||||
if let mon = pttMonitor { NSEvent.removeMonitor(mon) }
|
if let mon = pttMonitor { NSEvent.removeMonitor(mon) }
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
// Close all PM windows
|
|
||||||
for (_, pmWin) in pmWindows { pmWin.close() }
|
for (_, pmWin) in pmWindows { pmWin.close() }
|
||||||
pmWindows.removeAll()
|
pmWindows.removeAll()
|
||||||
// Close settings window
|
|
||||||
settingsWindowController?.close()
|
settingsWindowController?.close()
|
||||||
settingsWindowController = nil
|
settingsWindowController = nil
|
||||||
// Remove app menus we added
|
|
||||||
if let item = voiceMenuItem { NSApp.mainMenu?.removeItem(item) }
|
if let item = voiceMenuItem { NSApp.mainMenu?.removeItem(item) }
|
||||||
if let item = messagesMenuItem { NSApp.mainMenu?.removeItem(item) }
|
if let item = messagesMenuItem { NSApp.mainMenu?.removeItem(item) }
|
||||||
if let item = adminMenuItem { 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 appMenu = NSApp.mainMenu?.item(at: 0)?.submenu {
|
||||||
if let item = settingsMenuItem { appMenu.removeItem(item) }
|
if let item = settingsMenuItem { appMenu.removeItem(item) }
|
||||||
// Remove the separator we inserted before Quit
|
// Remove the separator we inserted before Quit
|
||||||
@@ -1091,6 +1299,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
}
|
}
|
||||||
client.setPushToTalk(false)
|
client.setPushToTalk(false)
|
||||||
stopScreenCapture()
|
stopScreenCapture()
|
||||||
|
if auxStreamId != 0 { stopAuxStream() }
|
||||||
if screenStreamId != 0 { client.stopStream(screenStreamId) }
|
if screenStreamId != 0 { client.stopStream(screenStreamId) }
|
||||||
if micStreamId != 0 { client.stopStream(micStreamId) }
|
if micStreamId != 0 { client.stopStream(micStreamId) }
|
||||||
client.onLevel = nil
|
client.onLevel = nil
|
||||||
@@ -1255,7 +1464,7 @@ extension MainWindowController: NSMenuDelegate {
|
|||||||
let info = ChannelEdit(id: ch.id, parentId: ch.parentId, name: ch.name,
|
let info = ChannelEdit(id: ch.id, parentId: ch.parentId, name: ch.name,
|
||||||
topic: ch.topic, passwordProtected: ch.passwordProtected,
|
topic: ch.topic, passwordProtected: ch.passwordProtected,
|
||||||
password: nil, maxUsers: ch.maxUsers,
|
password: nil, maxUsers: ch.maxUsers,
|
||||||
sortOrder: 0, audio: AudioConfig())
|
sortOrder: ch.sortOrder, audio: ch.audio)
|
||||||
let sheet = ChannelEditSheet(channels: channels, editing: info)
|
let sheet = ChannelEditSheet(channels: channels, editing: info)
|
||||||
sheet.onComplete = { [weak self] edited in
|
sheet.onComplete = { [weak self] edited in
|
||||||
guard let edited else { return }
|
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.
|
// Cached VAD slider position so we can restore it when the window reopens.
|
||||||
private var vadSliderValue: Double = 50
|
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
|
// MARK: - Init
|
||||||
|
|
||||||
init(client: VoiceCatClient, mainController: MainWindowController) {
|
init(client: VoiceCatClient, mainController: MainWindowController) {
|
||||||
@@ -57,13 +107,13 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
self.mainController = mainController
|
self.mainController = mainController
|
||||||
|
|
||||||
let window = NSWindow(
|
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],
|
styleMask: [.titled, .closable, .miniaturizable],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
)
|
)
|
||||||
window.title = "Audio Settings"
|
window.title = "Settings"
|
||||||
window.minSize = NSSize(width: 340, height: 220)
|
window.minSize = NSSize(width: 340, height: 380)
|
||||||
window.center()
|
window.center()
|
||||||
super.init(window: window)
|
super.init(window: window)
|
||||||
window.delegate = self
|
window.delegate = self
|
||||||
@@ -71,6 +121,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
buildUI()
|
buildUI()
|
||||||
syncFromMainController()
|
syncFromMainController()
|
||||||
loadInputDevices()
|
loadInputDevices()
|
||||||
|
loadAuxDevices()
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
@@ -120,6 +171,24 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
levelMeter.setAccessibilityLabel("Microphone input level")
|
levelMeter.setAccessibilityLabel("Microphone input level")
|
||||||
levelMeter.setAccessibilityHelp("Shows current microphone volume 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])
|
let inputModeRow = NSStackView(views: [inputModeLabel, inputModeControl])
|
||||||
inputModeRow.orientation = .horizontal
|
inputModeRow.orientation = .horizontal
|
||||||
inputModeRow.spacing = 8
|
inputModeRow.spacing = 8
|
||||||
@@ -140,7 +209,71 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
levelRow.orientation = .horizontal
|
levelRow.orientation = .horizontal
|
||||||
levelRow.spacing = 8
|
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.orientation = .vertical
|
||||||
stack.spacing = 12
|
stack.spacing = 12
|
||||||
stack.alignment = .leading
|
stack.alignment = .leading
|
||||||
@@ -155,9 +288,35 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
|
||||||
vadSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
vadSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
||||||
|
inputGainSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||||
levelMeter.widthAnchor.constraint(equalToConstant: 200),
|
levelMeter.widthAnchor.constraint(equalToConstant: 200),
|
||||||
devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
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
|
// MARK: - Sync from MainWindowController
|
||||||
@@ -173,9 +332,22 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
case .alwaysOn: inputModeControl.selectedSegment = 2
|
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
|
vadSlider.doubleValue = vadSliderValue
|
||||||
pttKeyLabel.stringValue = "(\(keyCodeName(mc.pttKeyCode)))"
|
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()
|
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() {
|
@objc private func changePttClicked() {
|
||||||
guard let mc = mainController else { return }
|
guard let mc = mainController else { return }
|
||||||
let sheet = PttKeyCaptureSheet(currentKeyCode: mc.pttKeyCode)
|
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)
|
// MARK: - Level meter (called by MainWindowController)
|
||||||
|
|
||||||
func updateLevel(rms: Float) {
|
func updateLevel(rms: Float) {
|
||||||
@@ -284,6 +548,12 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
0.1 * (1.0 - Float(vadSlider.doubleValue - 1.0) / 99.0)
|
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) {
|
private func presentSheet(_ vc: NSViewController) {
|
||||||
if let cvc = window?.contentViewController {
|
if let cvc = window?.contentViewController {
|
||||||
cvc.presentAsSheet(vc)
|
cvc.presentAsSheet(vc)
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ done
|
|||||||
# ── Resolve VCPKG_ROOT ───────────────────────────────────────────────────────────
|
# ── Resolve VCPKG_ROOT ───────────────────────────────────────────────────────────
|
||||||
# The apple-dev CMake cache records the vcpkg root it was configured with (Z_VCPKG_ROOT_DIR);
|
# 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
|
# 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
|
if [[ -z "${VCPKG_ROOT:-}" ]]; then
|
||||||
cache="$REPO_ROOT/build/apple-dev/CMakeCache.txt"
|
cache="$REPO_ROOT/build/apple-dev/CMakeCache.txt"
|
||||||
if [[ -f "$cache" ]]; then
|
if [[ -f "$cache" ]]; then
|
||||||
@@ -64,9 +65,16 @@ if [[ -z "${VCPKG_ROOT:-}" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
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
|
if [[ -z "${VCPKG_ROOT:-}" || ! -d "$VCPKG_ROOT" ]]; then
|
||||||
echo "error: VCPKG_ROOT is not set or does not exist." >&2
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "[build-xcframework] VCPKG_ROOT=$VCPKG_ROOT"
|
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
|
# 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.
|
# 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 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"
|
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)"
|
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). libtool -static concatenates
|
# Collect all .a files (libvoicecat.a + every vcpkg target .a + locally-built vendored static
|
||||||
# object files from all input archives; duplicate-object warnings are benign (the linker
|
# libs). libtool -static concatenates object files from all input archives; duplicate-object
|
||||||
# resolves duplicates at final link time). "no symbols" warnings are for empty AVX2/AVX512
|
# warnings are benign (the linker resolves duplicates at final link time). "no symbols"
|
||||||
# objects on arm64 — also benign.
|
# 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" )
|
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 "$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
|
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)"
|
echo "[build-xcframework] $slice_name -> $fat_lib ($(stat -f%z "$fat_lib") bytes, fat)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,91 +6,161 @@ namespace VoiceCat.App.Audio;
|
|||||||
// ── Scope types (mirror macOS ScreenAudioScope / ScreenAudioSelection) ────────
|
// ── Scope types (mirror macOS ScreenAudioScope / ScreenAudioSelection) ────────
|
||||||
|
|
||||||
public abstract record AppAudioScope;
|
public abstract record AppAudioScope;
|
||||||
public sealed record EntireDesktop : 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 OnlyApps(IReadOnlyList<int> Pids, IReadOnlyList<string> Names) : AppAudioScope;
|
||||||
public sealed record AllExceptApps(IReadOnlyList<int> Pids, IReadOnlyList<string> Names) : AppAudioScope;
|
public sealed record AllExceptApps(IReadOnlyList<int> Pids, IReadOnlyList<string> Names) : AppAudioScope;
|
||||||
|
|
||||||
public sealed record AudioAppInfo(int Pid, string DisplayName);
|
// 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 ─────────────────────────────────────────────────────────────────
|
// ── Enumerator ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public static class AudioSessionEnumerator
|
public static class AudioSessionEnumerator
|
||||||
{
|
{
|
||||||
// Returns all user-facing apps: visible-window processes (primary, like macOS
|
// Returns every app in the user's interactive session — windowed or not — so any of
|
||||||
// SCShareableContent.current) plus any background audio-session-only processes
|
// them can be picked for capture even before it starts producing audio. Process
|
||||||
// (e.g. Spotify in mini-player). Excludes VoiceCat itself and system processes.
|
// 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()
|
public static IReadOnlyList<AudioAppInfo> GetAudioApps()
|
||||||
{
|
{
|
||||||
var seen = new HashSet<int>();
|
|
||||||
var result = new List<AudioAppInfo>();
|
|
||||||
int selfPid = Environment.ProcessId;
|
int selfPid = Environment.ProcessId;
|
||||||
|
int sessionId = SafeCurrentSessionId();
|
||||||
|
var playingPids = GetActiveAudioPids();
|
||||||
|
|
||||||
// ── 1. Visible-window processes (EnumWindows) ─────────────────────────
|
// Group by executable name; keep the best representative PID per group.
|
||||||
// Same set macOS ScreenCaptureKit exposes: all apps with at least one
|
var groups = new Dictionary<string, AppGroup>(StringComparer.OrdinalIgnoreCase);
|
||||||
// visible top-level window. Shows apps even when not currently producing audio.
|
|
||||||
EnumWindows((hWnd, _) =>
|
foreach (var proc in Process.GetProcesses())
|
||||||
{
|
{
|
||||||
if (!IsWindowVisible(hWnd)) return true;
|
|
||||||
GetWindowThreadProcessId(hWnd, out uint pid);
|
|
||||||
if (pid == 0 || pid == (uint)selfPid || !seen.Add((int)pid)) return true;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var proc = Process.GetProcessById((int)pid);
|
if (proc.Id == selfPid) continue;
|
||||||
string name = proc.MainWindowTitle.Length > 0
|
if (proc.SessionId != sessionId) continue; // drop session-0 services
|
||||||
? $"{proc.ProcessName} — {proc.MainWindowTitle}"
|
string name = proc.ProcessName;
|
||||||
: proc.ProcessName;
|
if (string.IsNullOrEmpty(name)) continue;
|
||||||
if (!string.IsNullOrEmpty(proc.ProcessName))
|
|
||||||
result.Add(new AudioAppInfo((int)pid, name));
|
|
||||||
}
|
|
||||||
catch { /* process exited between EnumWindows and GetProcessById */ }
|
|
||||||
|
|
||||||
return true; // continue enumeration
|
bool hasWindow = proc.MainWindowHandle != IntPtr.Zero;
|
||||||
}, IntPtr.Zero);
|
bool playing = playingPids.Contains(proc.Id);
|
||||||
|
string title = hasWindow ? SafeWindowTitle(proc) : "";
|
||||||
|
string display = title.Length > 0 ? $"{name} — {title}" : name;
|
||||||
|
|
||||||
// ── 2. Background audio-session processes (WASAPI, supplement) ────────
|
if (groups.TryGetValue(name, out var g))
|
||||||
// Catches apps that produce audio but have no visible window (screen reader,
|
|
||||||
// background music player, etc.). Silently skipped if WASAPI is unavailable.
|
|
||||||
AppendAudioSessionApps(seen, selfPid, result);
|
|
||||||
|
|
||||||
return result.OrderBy(a => a.DisplayName, StringComparer.OrdinalIgnoreCase).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── EnumWindows P/Invoke ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern bool IsWindowVisible(IntPtr hWnd);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
|
||||||
|
|
||||||
// ── WASAPI audio session supplement ──────────────────────────────────────
|
|
||||||
|
|
||||||
private static void AppendAudioSessionApps(HashSet<int> seen, int selfPid,
|
|
||||||
List<AudioAppInfo> result)
|
|
||||||
{
|
{
|
||||||
|
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;
|
IMMDeviceEnumerator? enumerator = null;
|
||||||
IMMDevice? device = null;
|
IMMDeviceCollection? devices = null;
|
||||||
IAudioSessionManager2? manager = null;
|
|
||||||
IAudioSessionEnumerator? sessions = null;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(
|
enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(
|
||||||
Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!;
|
Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!;
|
||||||
|
|
||||||
enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out device);
|
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");
|
var mgr2Iid = new Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F");
|
||||||
device.Activate(ref mgr2Iid, 0x17 /*CLSCTX_ALL*/, IntPtr.Zero, out object mgr);
|
if (device.Activate(ref mgr2Iid, 0x17 /*CLSCTX_ALL*/, IntPtr.Zero, out object mgr) < 0)
|
||||||
|
return;
|
||||||
manager = (IAudioSessionManager2)mgr;
|
manager = (IAudioSessionManager2)mgr;
|
||||||
|
|
||||||
manager.GetSessionEnumerator(out sessions);
|
if (manager.GetSessionEnumerator(out sessions) < 0 || sessions == null) return;
|
||||||
sessions.GetCount(out int count);
|
sessions.GetCount(out int count);
|
||||||
|
|
||||||
for (int i = 0; i < count; i++)
|
for (int i = 0; i < count; i++)
|
||||||
@@ -98,32 +168,24 @@ public static class AudioSessionEnumerator
|
|||||||
IAudioSessionControl? ctrl = null;
|
IAudioSessionControl? ctrl = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
sessions.GetSession(i, out ctrl);
|
if (sessions.GetSession(i, out ctrl) < 0 || ctrl == null) continue;
|
||||||
|
ctrl.GetState(out int state);
|
||||||
|
if (state != 1 /*AudioSessionStateActive*/) continue;
|
||||||
|
|
||||||
var ctrl2 = (IAudioSessionControl2)ctrl;
|
var ctrl2 = (IAudioSessionControl2)ctrl;
|
||||||
ctrl2.GetProcessId(out uint pid);
|
ctrl2.GetProcessId(out uint pid);
|
||||||
|
if (pid != 0) pids.Add((int)pid);
|
||||||
int ipid = (int)pid;
|
|
||||||
if (pid == 0 || ipid == selfPid || !seen.Add(ipid)) continue;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var proc = Process.GetProcessById(ipid);
|
|
||||||
if (!string.IsNullOrEmpty(proc.ProcessName))
|
|
||||||
result.Add(new AudioAppInfo(ipid, proc.ProcessName));
|
|
||||||
}
|
|
||||||
catch { /* exited */ }
|
|
||||||
}
|
}
|
||||||
catch { /* stale session */ }
|
catch { /* stale session */ }
|
||||||
finally { if (ctrl != null) Marshal.ReleaseComObject(ctrl); }
|
finally { if (ctrl != null) Marshal.ReleaseComObject(ctrl); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* no audio device or WASAPI unavailable — ignore */ }
|
catch { /* device went away */ }
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (sessions != null) Marshal.ReleaseComObject(sessions);
|
if (sessions != null) Marshal.ReleaseComObject(sessions);
|
||||||
if (manager != null) Marshal.ReleaseComObject(manager);
|
if (manager != null) Marshal.ReleaseComObject(manager);
|
||||||
if (device != null) Marshal.ReleaseComObject(device);
|
if (device != null) Marshal.ReleaseComObject(device);
|
||||||
if (enumerator != null) Marshal.ReleaseComObject(enumerator);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,13 +195,21 @@ public static class AudioSessionEnumerator
|
|||||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
internal interface IMMDeviceEnumerator
|
internal interface IMMDeviceEnumerator
|
||||||
{
|
{
|
||||||
[PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IntPtr devices);
|
[PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IMMDeviceCollection devices);
|
||||||
[PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
|
[PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
|
||||||
[PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device);
|
[PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device);
|
||||||
[PreserveSig] int RegisterEndpointNotificationCallback(IntPtr client);
|
[PreserveSig] int RegisterEndpointNotificationCallback(IntPtr client);
|
||||||
[PreserveSig] int UnregisterEndpointNotificationCallback(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"),
|
[ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"),
|
||||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
internal interface IMMDevice
|
internal interface IMMDevice
|
||||||
|
|||||||
443
clients/windows/VoiceCat.App/Audio/InputDeviceCapture.cs
Normal file
443
clients/windows/VoiceCat.App/Audio/InputDeviceCapture.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -22,15 +22,14 @@ public sealed class ProcessAudioMixer : IDisposable
|
|||||||
private VoiceCatClient? _client;
|
private VoiceCatClient? _client;
|
||||||
private uint _streamId;
|
private uint _streamId;
|
||||||
|
|
||||||
public void Start(AppAudioScope scope, VoiceCatClient client, uint streamId,
|
public void Start(AppAudioScope scope, VoiceCatClient client, uint streamId)
|
||||||
IReadOnlyList<AudioAppInfo> allApps)
|
|
||||||
{
|
{
|
||||||
if (_running) return;
|
if (_running) return;
|
||||||
_client = client;
|
_client = client;
|
||||||
_streamId = streamId;
|
_streamId = streamId;
|
||||||
|
|
||||||
var pids = ResolvePids(scope, allApps);
|
var specs = ResolveCaptures(scope);
|
||||||
if (pids.Count == 0)
|
if (specs.Count == 0)
|
||||||
{
|
{
|
||||||
// nothing to capture — scope resolved to empty set
|
// nothing to capture — scope resolved to empty set
|
||||||
return;
|
return;
|
||||||
@@ -38,14 +37,15 @@ public sealed class ProcessAudioMixer : IDisposable
|
|||||||
|
|
||||||
lock (_frameLock)
|
lock (_frameLock)
|
||||||
{
|
{
|
||||||
_latestFrames = new List<short[]>(new short[pids.Count][]);
|
_latestFrames = new List<short[]>(new short[specs.Count][]);
|
||||||
_activeChannels = Channels;
|
_activeChannels = Channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < pids.Count; i++)
|
for (int i = 0; i < specs.Count; i++)
|
||||||
{
|
{
|
||||||
int captureIndex = i;
|
int captureIndex = i;
|
||||||
var cap = new ProcessLoopbackCapture(pids[i], ProcessLoopbackCapture.Mode.Include);
|
var (pid, mode) = specs[i];
|
||||||
|
var cap = new ProcessLoopbackCapture(pid, mode);
|
||||||
cap.PcmFrameReady += (pcm, spc, ch) => OnCaptureFrame(captureIndex, pcm, ch);
|
cap.PcmFrameReady += (pcm, spc, ch) => OnCaptureFrame(captureIndex, pcm, ch);
|
||||||
_captures.Add(cap);
|
_captures.Add(cap);
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,6 @@ public sealed class ProcessAudioMixer : IDisposable
|
|||||||
for (int i = 0; i < frameLen; i++)
|
for (int i = 0; i < frameLen; i++)
|
||||||
{
|
{
|
||||||
int sum = mix[i] + frame[i];
|
int sum = mix[i] + frame[i];
|
||||||
// Saturating clamp
|
|
||||||
mix[i] = (short)Math.Clamp(sum, short.MinValue, short.MaxValue);
|
mix[i] = (short)Math.Clamp(sum, short.MinValue, short.MaxValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,18 +116,21 @@ public sealed class ProcessAudioMixer : IDisposable
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// For "AllExcept": enumerate all running audio apps and exclude the specified ones.
|
// Resolve the scope to the WASAPI captures to open:
|
||||||
// For "OnlyApps": use their PIDs directly.
|
// OnlyApps → one INCLUDE capture per selected process tree.
|
||||||
private static List<int> ResolvePids(AppAudioScope scope, IReadOnlyList<AudioAppInfo> allApps)
|
// 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
|
return scope switch
|
||||||
{
|
{
|
||||||
OnlyApps o => [.. o.Pids],
|
OnlyApps o => o.Pids.Select(p => (p, ProcessLoopbackCapture.Mode.Include)).ToList(),
|
||||||
AllExceptApps a =>
|
AllExceptApps a when a.Pids.Count > 0 =>
|
||||||
allApps
|
[(a.Pids[0], ProcessLoopbackCapture.Mode.Exclude)],
|
||||||
.Where(app => !a.Pids.Contains(app.Pid))
|
// Entire desktop minus VoiceCat itself: EXCLUDE our own process tree.
|
||||||
.Select(app => app.Pid)
|
EntireDesktop { ExcludeSelf: true } =>
|
||||||
.ToList(),
|
[(Environment.ProcessId, ProcessLoopbackCapture.Mode.Exclude)],
|
||||||
_ => [],
|
_ => [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
// Single-process WASAPI loopback capture via AUDIOCLIENT_ACTIVATION_PARAMS
|
// Process loopback requires MTA activation; blocking activation from the WinForms STA
|
||||||
// (Windows 10 2004+ / Build 19041+).
|
// deadlocks COM completion. The returned interfaces also reject RCW QueryInterface, so audio
|
||||||
//
|
// calls use explicitly owned raw pointers and vtable dispatch.
|
||||||
// Threading: ALL WASAPI init runs on the capture thread (MTA). If called from the
|
|
||||||
// WinForms UI thread (STA), ActivateAudioInterfaceAsync fires ActivateCompleted on
|
|
||||||
// an MTA pool thread; COM marshals that back to the STA pump — but the STA thread is
|
|
||||||
// blocked on CompletionEvent.Wait → deadlock. MTA capture thread avoids this.
|
|
||||||
//
|
|
||||||
// COM QI policy: the COM objects returned by the process-loopback activation path
|
|
||||||
// reject QueryInterface for their own IIDs under .NET's RCW mechanism. Every call
|
|
||||||
// to IAudioClient and IAudioCaptureClient is therefore dispatched via raw vtable
|
|
||||||
// pointer arithmetic, bypassing .NET COM interop entirely.
|
|
||||||
namespace VoiceCat.App.Audio;
|
namespace VoiceCat.App.Audio;
|
||||||
|
|
||||||
public sealed class ProcessLoopbackCapture : IDisposable
|
public sealed class ProcessLoopbackCapture : IDisposable
|
||||||
|
|||||||
@@ -12,12 +12,18 @@ public sealed class AppAudioPickerDialog : Form
|
|||||||
private readonly RadioButton _rdoAll;
|
private readonly RadioButton _rdoAll;
|
||||||
private readonly RadioButton _rdoOnly;
|
private readonly RadioButton _rdoOnly;
|
||||||
private readonly RadioButton _rdoExcept;
|
private readonly RadioButton _rdoExcept;
|
||||||
|
private readonly CheckBox _chkExcludeSelf;
|
||||||
|
private readonly TextBox _txtFilter;
|
||||||
private readonly ListView _appList;
|
private readonly ListView _appList;
|
||||||
private readonly Label _lblApps;
|
private readonly Label _lblApps;
|
||||||
|
|
||||||
// Snapshot taken when the dialog opens (refresh on open, not on every check change).
|
// Snapshot taken when the dialog opens (refresh on open, not on every check change).
|
||||||
private IReadOnlyList<AudioAppInfo> _apps = [];
|
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 AppAudioScope? ChosenScope { get; private set; }
|
||||||
|
|
||||||
public AppAudioPickerDialog()
|
public AppAudioPickerDialog()
|
||||||
@@ -50,51 +56,78 @@ public sealed class AppAudioPickerDialog : Form
|
|||||||
_rdoOnly.CheckedChanged += OnModeChanged;
|
_rdoOnly.CheckedChanged += OnModeChanged;
|
||||||
_rdoExcept.CheckedChanged += OnModeChanged;
|
_rdoExcept.CheckedChanged += OnModeChanged;
|
||||||
|
|
||||||
// ── App list ────────────────────────────────────────────────────────
|
// ── Self-exclude (echo prevention) ───────────────────────────────────
|
||||||
_lblApps = new Label
|
// 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 = "Apps with active audio sessions:",
|
Text = "E&xclude VoiceCat's own audio (prevents echo)",
|
||||||
AutoSize = true,
|
Checked = true,
|
||||||
Location = new Point(12, 90),
|
Location = new Point(28, 84),
|
||||||
Visible = false,
|
Size = new Size(344, 20),
|
||||||
TabIndex = 3,
|
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
|
_appList = new ListView
|
||||||
{
|
{
|
||||||
Location = new Point(12, 112),
|
Location = new Point(12, 160),
|
||||||
Size = new Size(360, 180),
|
Size = new Size(360, 150),
|
||||||
CheckBoxes = true,
|
CheckBoxes = true,
|
||||||
View = View.List,
|
View = View.List,
|
||||||
Visible = false,
|
Visible = false,
|
||||||
TabIndex = 4,
|
TabIndex = 6,
|
||||||
FullRowSelect = true,
|
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 ─────────────────────────────────────────────────────────
|
// ── Buttons ─────────────────────────────────────────────────────────
|
||||||
var btnShare = new Button
|
var btnShare = new Button
|
||||||
{
|
{
|
||||||
Text = "&Share",
|
Text = "&Share",
|
||||||
DialogResult = DialogResult.OK,
|
DialogResult = DialogResult.OK,
|
||||||
Location = new Point(216, 308),
|
Location = new Point(216, 320),
|
||||||
Size = new Size(75, 27),
|
Size = new Size(75, 27),
|
||||||
TabIndex = 5,
|
TabIndex = 7,
|
||||||
};
|
};
|
||||||
var btnCancel = new Button
|
var btnCancel = new Button
|
||||||
{
|
{
|
||||||
Text = "&Cancel",
|
Text = "&Cancel",
|
||||||
DialogResult = DialogResult.Cancel,
|
DialogResult = DialogResult.Cancel,
|
||||||
Location = new Point(297, 308),
|
Location = new Point(297, 320),
|
||||||
Size = new Size(75, 27),
|
Size = new Size(75, 27),
|
||||||
TabIndex = 6,
|
TabIndex = 8,
|
||||||
};
|
};
|
||||||
btnShare.Click += OnShareClick;
|
btnShare.Click += OnShareClick;
|
||||||
|
|
||||||
AcceptButton = btnShare;
|
AcceptButton = btnShare;
|
||||||
CancelButton = btnCancel;
|
CancelButton = btnCancel;
|
||||||
AutoScaleMode = AutoScaleMode.Font;
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
ClientSize = new Size(384, 348);
|
ClientSize = new Size(384, 360);
|
||||||
Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _lblApps, _appList, btnShare, btnCancel]);
|
Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _chkExcludeSelf, _lblApps, _txtFilter,
|
||||||
|
_appList, btnShare, btnCancel]);
|
||||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
MaximizeBox = false;
|
MaximizeBox = false;
|
||||||
MinimizeBox = false;
|
MinimizeBox = false;
|
||||||
@@ -111,41 +144,105 @@ public sealed class AppAudioPickerDialog : Form
|
|||||||
private void RefreshAppList()
|
private void RefreshAppList()
|
||||||
{
|
{
|
||||||
_apps = AudioSessionEnumerator.GetAudioApps();
|
_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();
|
_appList.Items.Clear();
|
||||||
foreach (var app in _apps)
|
foreach (var app in _apps)
|
||||||
_appList.Items.Add(new ListViewItem($"{app.DisplayName} (PID {app.Pid})") { Tag = app.Pid });
|
{
|
||||||
|
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)
|
private void OnModeChanged(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
bool showList = _rdoOnly.Checked || _rdoExcept.Checked;
|
bool showList = _rdoOnly.Checked || _rdoExcept.Checked;
|
||||||
_lblApps.Visible = showList;
|
_lblApps.Visible = showList;
|
||||||
|
_txtFilter.Visible = showList;
|
||||||
_appList.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)
|
if (showList && _appList.Items.Count == 0)
|
||||||
RefreshAppList();
|
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)
|
private void OnShareClick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (_rdoAll.Checked)
|
if (_rdoAll.Checked)
|
||||||
{
|
{
|
||||||
ChosenScope = new EntireDesktop();
|
ChosenScope = new EntireDesktop(_chkExcludeSelf.Checked);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var checkedPids = new List<int>();
|
if (_checked.Count == 0)
|
||||||
var checkedNames = new List<string>();
|
|
||||||
foreach (ListViewItem item in _appList.CheckedItems)
|
|
||||||
{
|
|
||||||
if (item.Tag is int pid)
|
|
||||||
{
|
|
||||||
checkedPids.Add(pid);
|
|
||||||
checkedNames.Add(item.Text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkedPids.Count == 0)
|
|
||||||
{
|
{
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
"Select at least one app, or choose 'Entire desktop'.",
|
"Select at least one app, or choose 'Entire desktop'.",
|
||||||
@@ -156,11 +253,11 @@ public sealed class AppAudioPickerDialog : Form
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ChosenScope = _rdoOnly.Checked
|
var pids = _checked.Keys.ToList();
|
||||||
? new OnlyApps(checkedPids, checkedNames)
|
var names = _checked.Values.ToList();
|
||||||
: new AllExceptApps(checkedPids, checkedNames);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>All apps that were visible in the list when the user clicked Share.</summary>
|
ChosenScope = _rdoOnly.Checked
|
||||||
public IReadOnlyList<AudioAppInfo> VisibleApps => _apps;
|
? new OnlyApps(pids, names)
|
||||||
|
: new AllExceptApps(pids, names);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
574
clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs
Normal file
574
clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public partial class ConnectDialog : Form
|
|||||||
public VoiceCatClient? ConnectedClient { get; private set; }
|
public VoiceCatClient? ConnectedClient { get; private set; }
|
||||||
public uint SelfUserId { get; private set; }
|
public uint SelfUserId { get; private set; }
|
||||||
public string Nickname { get; private set; } = "";
|
public string Nickname { get; private set; } = "";
|
||||||
|
public string ServerName { get; private set; } = "";
|
||||||
|
|
||||||
public ConnectDialog()
|
public ConnectDialog()
|
||||||
{
|
{
|
||||||
@@ -91,13 +92,8 @@ public partial class ConnectDialog : Form
|
|||||||
|
|
||||||
private void BtnConnect_Click(object? sender, EventArgs e)
|
private void BtnConnect_Click(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
Console.WriteLine("[ConnectDialog] BtnConnect_Click fired");
|
|
||||||
if (lstServers.SelectedItem is not SavedServer server)
|
if (lstServers.SelectedItem is not SavedServer server)
|
||||||
{
|
|
||||||
Console.WriteLine("[ConnectDialog] no SavedServer selected — ignoring click");
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
Console.WriteLine($"[ConnectDialog] selected server: Host={server.Host} Port={server.Port} AuthMode={server.AuthMode}");
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
StartConnect(server);
|
StartConnect(server);
|
||||||
@@ -115,22 +111,20 @@ public partial class ConnectDialog : Form
|
|||||||
SetBusy(true);
|
SetBusy(true);
|
||||||
lblStatus.Text = "Connecting...";
|
lblStatus.Text = "Connecting...";
|
||||||
|
|
||||||
|
ServerName = string.IsNullOrWhiteSpace(server.DisplayName)
|
||||||
|
? $"{server.Host}:{server.Port}"
|
||||||
|
: server.DisplayName;
|
||||||
|
|
||||||
string tofuDir = Path.GetDirectoryName(ServerListStore.TofuStorePath)!;
|
string tofuDir = Path.GetDirectoryName(ServerListStore.TofuStorePath)!;
|
||||||
Console.WriteLine($"[ConnectDialog] tofu store dir: {tofuDir}");
|
|
||||||
Directory.CreateDirectory(tofuDir);
|
Directory.CreateDirectory(tofuDir);
|
||||||
|
|
||||||
Console.WriteLine("[ConnectDialog] creating VoiceCatClient...");
|
|
||||||
_client = new VoiceCatClient("VoiceCat-Windows", VoiceCatClient.VersionString,
|
_client = new VoiceCatClient("VoiceCat-Windows", VoiceCatClient.VersionString,
|
||||||
VcLogLevel.Info, ServerListStore.TofuStorePath);
|
VcLogLevel.Info, ServerListStore.TofuStorePath);
|
||||||
Console.WriteLine("[ConnectDialog] VoiceCatClient created OK");
|
|
||||||
_client.EventReceived += OnEvent;
|
_client.EventReceived += OnEvent;
|
||||||
_identityDialogShown = false;
|
_identityDialogShown = false;
|
||||||
_pumpTimer.Start();
|
_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);
|
var connectResult = _client.Connect(server.Host, server.Port);
|
||||||
Console.WriteLine($"[ConnectDialog] Connect() returned {connectResult}");
|
|
||||||
if (connectResult != VcResult.Ok)
|
if (connectResult != VcResult.Ok)
|
||||||
{
|
{
|
||||||
lblStatus.Text = $"Connect failed: {connectResult}";
|
lblStatus.Text = $"Connect failed: {connectResult}";
|
||||||
@@ -141,9 +135,7 @@ public partial class ConnectDialog : Form
|
|||||||
if (server.AuthMode == AuthMode.Guest)
|
if (server.AuthMode == AuthMode.Guest)
|
||||||
{
|
{
|
||||||
Nickname = string.IsNullOrWhiteSpace(server.LastNickname) ? Environment.UserName : server.LastNickname;
|
Nickname = string.IsNullOrWhiteSpace(server.LastNickname) ? Environment.UserName : server.LastNickname;
|
||||||
Console.WriteLine($"[ConnectDialog] calling AuthenticateGuest({Nickname})...");
|
_client.AuthenticateGuest(Nickname);
|
||||||
var authResult = _client.AuthenticateGuest(Nickname);
|
|
||||||
Console.WriteLine($"[ConnectDialog] AuthenticateGuest() returned {authResult}");
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -164,15 +156,12 @@ public partial class ConnectDialog : Form
|
|||||||
password = pwDlg.Password;
|
password = pwDlg.Password;
|
||||||
}
|
}
|
||||||
Nickname = server.SavedUsername ?? "";
|
Nickname = server.SavedUsername ?? "";
|
||||||
Console.WriteLine($"[ConnectDialog] calling AuthenticateUser({Nickname})...");
|
_client.AuthenticateUser(server.SavedUsername ?? "", password);
|
||||||
var authResult = _client.AuthenticateUser(server.SavedUsername ?? "", password);
|
|
||||||
Console.WriteLine($"[ConnectDialog] AuthenticateUser() returned {authResult}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnEvent(VoiceCatEvent ev)
|
private void OnEvent(VoiceCatEvent ev)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[ConnectDialog] event: {ev}");
|
|
||||||
switch (ev.Type)
|
switch (ev.Type)
|
||||||
{
|
{
|
||||||
case VcEventType.ConnectionState:
|
case VcEventType.ConnectionState:
|
||||||
@@ -220,24 +209,18 @@ public partial class ConnectDialog : Form
|
|||||||
|
|
||||||
private void HandleServerIdentity(VcTofuStatus status, string certFingerprintHex)
|
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 (_identityDialogShown) return; // one decision per connect attempt
|
||||||
if (status == VcTofuStatus.Matched)
|
if (status == VcTofuStatus.Matched)
|
||||||
{
|
{
|
||||||
// Silent success path — no dialog. See ServerIdentityDialog's doc comment.
|
// Silent success path — no dialog. See ServerIdentityDialog's doc comment.
|
||||||
Console.WriteLine("[ConnectDialog] status=Matched -> auto-confirming, no dialog");
|
|
||||||
_client!.ConfirmServerIdentity(true);
|
_client!.ConfirmServerIdentity(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_identityDialogShown = true;
|
_identityDialogShown = true;
|
||||||
Console.WriteLine("[ConnectDialog] showing ServerIdentityDialog...");
|
|
||||||
using var dlg = new ServerIdentityDialog(status, certFingerprintHex, _client!.GetServerIdentityDisplay());
|
using var dlg = new ServerIdentityDialog(status, certFingerprintHex, _client!.GetServerIdentityDisplay());
|
||||||
var dlgResult = dlg.ShowDialog(this);
|
bool accept = dlg.ShowDialog(this) == DialogResult.OK;
|
||||||
Console.WriteLine($"[ConnectDialog] ServerIdentityDialog closed with {dlgResult}");
|
_client.ConfirmServerIdentity(accept);
|
||||||
bool accept = dlgResult == DialogResult.OK;
|
|
||||||
var confirmResult = _client.ConfirmServerIdentity(accept);
|
|
||||||
Console.WriteLine($"[ConnectDialog] ConfirmServerIdentity({accept}) returned {confirmResult}");
|
|
||||||
if (!accept) lblStatus.Text = "Server identity rejected.";
|
if (!accept) lblStatus.Text = "Server identity rejected.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs
generated
129
clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs
generated
@@ -36,21 +36,10 @@ partial class MainForm
|
|||||||
// Voice control panel (docked Bottom)
|
// Voice control panel (docked Bottom)
|
||||||
private Panel pnlVoice = null!;
|
private Panel pnlVoice = null!;
|
||||||
private FlowLayoutPanel flpVoiceTop = null!;
|
private FlowLayoutPanel flpVoiceTop = null!;
|
||||||
private FlowLayoutPanel flpVoiceBottom = null!;
|
|
||||||
private CheckBox chkMute = null!;
|
private CheckBox chkMute = null!;
|
||||||
private CheckBox chkDeafen = 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 Label lblLevel = null!;
|
||||||
private ProgressBar pbLevel = null!;
|
private ProgressBar pbLevel = null!;
|
||||||
private Label lblVadThreshold = null!;
|
|
||||||
private TrackBar trkVadThreshold = null!;
|
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
@@ -79,21 +68,10 @@ partial class MainForm
|
|||||||
trkOutputVolume = new TrackBar();
|
trkOutputVolume = new TrackBar();
|
||||||
pnlVoice = new Panel();
|
pnlVoice = new Panel();
|
||||||
flpVoiceTop = new FlowLayoutPanel();
|
flpVoiceTop = new FlowLayoutPanel();
|
||||||
flpVoiceBottom = new FlowLayoutPanel();
|
|
||||||
chkMute = new CheckBox();
|
chkMute = new CheckBox();
|
||||||
chkDeafen = 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();
|
lblLevel = new Label();
|
||||||
pbLevel = new ProgressBar();
|
pbLevel = new ProgressBar();
|
||||||
lblVadThreshold = new Label();
|
|
||||||
trkVadThreshold = new TrackBar();
|
|
||||||
menuStrip = new MenuStrip();
|
menuStrip = new MenuStrip();
|
||||||
toolStrip = new ToolStrip();
|
toolStrip = new ToolStrip();
|
||||||
tsbJoinVoice = new ToolStripButton();
|
tsbJoinVoice = new ToolStripButton();
|
||||||
@@ -140,6 +118,13 @@ partial class MainForm
|
|||||||
splitLeft.Panel1MinSize = 100;
|
splitLeft.Panel1MinSize = 100;
|
||||||
splitLeft.Panel2MinSize = 80;
|
splitLeft.Panel2MinSize = 80;
|
||||||
splitLeft.TabIndex = 0;
|
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(tvChannels);
|
||||||
splitLeft.Panel1.Controls.Add(lblChannels);
|
splitLeft.Panel1.Controls.Add(lblChannels);
|
||||||
splitLeft.Panel2.Controls.Add(lstUsers);
|
splitLeft.Panel2.Controls.Add(lstUsers);
|
||||||
@@ -214,6 +199,10 @@ partial class MainForm
|
|||||||
splitMain.Dock = DockStyle.Fill;
|
splitMain.Dock = DockStyle.Fill;
|
||||||
splitMain.Panel1MinSize = 150;
|
splitMain.Panel1MinSize = 150;
|
||||||
splitMain.TabIndex = 1;
|
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.Panel1.Controls.Add(splitLeft);
|
||||||
splitMain.Panel2.Controls.Add(tblRight);
|
splitMain.Panel2.Controls.Add(tblRight);
|
||||||
|
|
||||||
@@ -230,71 +219,15 @@ partial class MainForm
|
|||||||
chkDeafen.Margin = new Padding(0, 4, 12, 0);
|
chkDeafen.Margin = new Padding(0, 4, 12, 0);
|
||||||
chkDeafen.TabIndex = 2;
|
chkDeafen.TabIndex = 2;
|
||||||
|
|
||||||
var lblMode = new Label { Text = "Mode:", AutoSize = true, Margin = new Padding(0, 5, 4, 0) };
|
flpVoiceTop.Dock = DockStyle.Fill;
|
||||||
|
|
||||||
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.AutoSize = false;
|
flpVoiceTop.AutoSize = false;
|
||||||
flpVoiceTop.Padding = new Padding(4, 2, 4, 0);
|
flpVoiceTop.Padding = new Padding(4, 2, 4, 0);
|
||||||
flpVoiceTop.Controls.Add(chkMute);
|
flpVoiceTop.Controls.Add(chkMute);
|
||||||
flpVoiceTop.Controls.Add(chkDeafen);
|
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.Text = "Level:";
|
||||||
lblLevel.AutoSize = true;
|
lblLevel.AutoSize = true;
|
||||||
lblLevel.Margin = new Padding(0, 5, 4, 0);
|
lblLevel.Margin = new Padding(12, 5, 4, 0);
|
||||||
|
|
||||||
pbLevel.AccessibleName = "Microphone level";
|
pbLevel.AccessibleName = "Microphone level";
|
||||||
pbLevel.AccessibleDescription = "Current input level from the microphone.";
|
pbLevel.AccessibleDescription = "Current input level from the microphone.";
|
||||||
@@ -305,42 +238,14 @@ partial class MainForm
|
|||||||
pbLevel.Style = ProgressBarStyle.Continuous;
|
pbLevel.Style = ProgressBarStyle.Continuous;
|
||||||
pbLevel.TabStop = false;
|
pbLevel.TabStop = false;
|
||||||
|
|
||||||
lblVadThreshold.Text = "Sensitivity:";
|
flpVoiceTop.Controls.Add(lblLevel);
|
||||||
lblVadThreshold.AutoSize = true;
|
flpVoiceTop.Controls.Add(pbLevel);
|
||||||
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);
|
|
||||||
|
|
||||||
pnlVoice.Dock = DockStyle.Bottom;
|
pnlVoice.Dock = DockStyle.Bottom;
|
||||||
pnlVoice.Height = 68;
|
pnlVoice.Height = 38;
|
||||||
pnlVoice.BorderStyle = BorderStyle.FixedSingle;
|
pnlVoice.BorderStyle = BorderStyle.FixedSingle;
|
||||||
pnlVoice.Padding = new Padding(0);
|
pnlVoice.Padding = new Padding(0);
|
||||||
pnlVoice.Controls.Add(flpVoiceBottom); // Fill — added first
|
pnlVoice.Controls.Add(flpVoiceTop);
|
||||||
pnlVoice.Controls.Add(flpVoiceTop); // Top — added last
|
|
||||||
|
|
||||||
// ── Toolbar ───────────────────────────────────────────────────────────
|
// ── Toolbar ───────────────────────────────────────────────────────────
|
||||||
tsbJoinVoice.Text = "Join Voice";
|
tsbJoinVoice.Text = "Join Voice";
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
using VoiceCat.App.Audio;
|
using VoiceCat.App.Audio;
|
||||||
|
using VoiceCat.App.Models;
|
||||||
|
using VoiceCat.App.Native;
|
||||||
|
using VoiceCat.App.Notifications;
|
||||||
using VoiceCat.Interop;
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
namespace VoiceCat.App.Forms;
|
namespace VoiceCat.App.Forms;
|
||||||
@@ -12,6 +15,8 @@ public partial class MainForm : Form
|
|||||||
private readonly uint _selfUserId;
|
private readonly uint _selfUserId;
|
||||||
private readonly string _nickname;
|
private readonly string _nickname;
|
||||||
private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 };
|
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
|
// Channel / user state
|
||||||
private uint _currentChannelId;
|
private uint _currentChannelId;
|
||||||
@@ -24,7 +29,11 @@ public partial class MainForm : Form
|
|||||||
private uint _micStreamId; // 0 = not started
|
private uint _micStreamId; // 0 = not started
|
||||||
private uint _screenStreamId; // 0 = not sharing screen audio
|
private uint _screenStreamId; // 0 = not sharing screen audio
|
||||||
private ProcessAudioMixer? _screenMixer; // non-null only in per-app capture mode
|
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 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 _serverMuted;
|
||||||
private bool _serverDeafened;
|
private bool _serverDeafened;
|
||||||
|
|
||||||
@@ -35,18 +44,21 @@ public partial class MainForm : Form
|
|||||||
private ToolStripMenuItem _miJoinVoice = null!;
|
private ToolStripMenuItem _miJoinVoice = null!;
|
||||||
private ToolStripMenuItem _miScreenShare = null!;
|
private ToolStripMenuItem _miScreenShare = null!;
|
||||||
|
|
||||||
public MainForm(VoiceCatClient client, uint selfUserId, string nickname)
|
public MainForm(VoiceCatClient client, uint selfUserId, string nickname, string serverName)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_client = client;
|
_client = client;
|
||||||
_selfUserId = selfUserId;
|
_selfUserId = selfUserId;
|
||||||
_nickname = nickname;
|
_nickname = nickname;
|
||||||
|
|
||||||
Text = $"VoiceCat — {nickname}";
|
Text = string.IsNullOrWhiteSpace(serverName)
|
||||||
|
? $"VoiceCat — {nickname}"
|
||||||
|
: $"VoiceCat — {nickname} @ {serverName}";
|
||||||
|
|
||||||
_client.EventReceived += OnEvent;
|
_client.EventReceived += OnEvent;
|
||||||
_client.LevelChanged += OnLevelChanged;
|
_client.LevelChanged += OnLevelChanged;
|
||||||
_pumpTimer.Tick += (_, _) => _client.PumpEvents();
|
_pumpTimer.Tick += (_, _) => _client.PumpEvents();
|
||||||
|
_pumpTimer.Tick += (_, _) => PttWatchdog();
|
||||||
|
|
||||||
_ownPermissions = SafeGetPermissions();
|
_ownPermissions = SafeGetPermissions();
|
||||||
BuildMenus();
|
BuildMenus();
|
||||||
@@ -62,7 +74,7 @@ public partial class MainForm : Form
|
|||||||
tvChannels.DoubleClick += TvChannels_DoubleClick;
|
tvChannels.DoubleClick += TvChannels_DoubleClick;
|
||||||
tvChannels.KeyDown += TvChannels_KeyDown;
|
tvChannels.KeyDown += TvChannels_KeyDown;
|
||||||
lstUsers.DoubleClick += (_, _) => OpenUserTuning();
|
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
|
// Compose
|
||||||
txtCompose.KeyDown += TxtCompose_KeyDown;
|
txtCompose.KeyDown += TxtCompose_KeyDown;
|
||||||
@@ -78,26 +90,28 @@ public partial class MainForm : Form
|
|||||||
// Voice controls
|
// Voice controls
|
||||||
chkMute.CheckedChanged += (_, _) => ApplySelfMute();
|
chkMute.CheckedChanged += (_, _) => ApplySelfMute();
|
||||||
chkDeafen.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_KeyDown;
|
||||||
KeyDown += MainForm_HotkeyDown;
|
KeyDown += MainForm_HotkeyDown;
|
||||||
KeyUp += MainForm_KeyUp;
|
KeyUp += MainForm_KeyUp;
|
||||||
Deactivate += (_, _) =>
|
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();
|
BootstrapFromServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ApplyPersistedVoiceSettings() =>
|
||||||
|
_pttKey = (Keys)_voiceSettings.PttKey;
|
||||||
|
|
||||||
// ── Startup ──────────────────────────────────────────────────────────────
|
// ── Startup ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void BootstrapFromServer()
|
private void BootstrapFromServer()
|
||||||
@@ -118,6 +132,8 @@ public partial class MainForm : Form
|
|||||||
RefreshUserList();
|
RefreshUserList();
|
||||||
UpdateStatusLabel();
|
UpdateStatusLabel();
|
||||||
AddActivity($"Connected to server as {_nickname}");
|
AddActivity($"Connected to server as {_nickname}");
|
||||||
|
_feedback.PlaySound(SoundEvent.Login);
|
||||||
|
_feedback.Speak("Connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnLoad(EventArgs e)
|
protected override void OnLoad(EventArgs e)
|
||||||
@@ -125,7 +141,6 @@ public partial class MainForm : Form
|
|||||||
base.OnLoad(e);
|
base.OnLoad(e);
|
||||||
splitMain.SplitterDistance = Math.Min(220, splitMain.Width - 304);
|
splitMain.SplitterDistance = Math.Min(220, splitMain.Width - 304);
|
||||||
splitLeft.SplitterDistance = Math.Min(260, splitLeft.Height - 84);
|
splitLeft.SplitterDistance = Math.Min(260, splitLeft.Height - 84);
|
||||||
LoadInputDevices();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Menu / context menu builders ─────────────────────────────────────────
|
// ── Menu / context menu builders ─────────────────────────────────────────
|
||||||
@@ -161,6 +176,35 @@ public partial class MainForm : Form
|
|||||||
messagesMenu.DropDownItems.Add(miNewPm);
|
messagesMenu.DropDownItems.Add(miNewPm);
|
||||||
menuStrip.Items.Add(messagesMenu);
|
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
|
// Admin menu — only if permitted
|
||||||
if (_ownPermissions.CanAdminAccounts)
|
if (_ownPermissions.CanAdminAccounts)
|
||||||
{
|
{
|
||||||
@@ -201,6 +245,7 @@ public partial class MainForm : Form
|
|||||||
ctx.Items.Add("&Delete channel...", null, (_, _) => DeleteSelectedChannel());
|
ctx.Items.Add("&Delete channel...", null, (_, _) => DeleteSelectedChannel());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
ctx.Opened += (_, _) => SelectFirstMenuItem(ctx);
|
||||||
tvChannels.ContextMenuStrip = ctx;
|
tvChannels.ContextMenuStrip = ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,9 +292,26 @@ public partial class MainForm : Form
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
ctx.Opened += (_, _) => SelectFirstMenuItem(ctx);
|
||||||
lstUsers.ContextMenuStrip = 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 ────────────────────────────────────────────────────────
|
// ── Event dispatch ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void OnEvent(VoiceCatEvent ev)
|
private void OnEvent(VoiceCatEvent ev)
|
||||||
@@ -287,10 +349,21 @@ public partial class MainForm : Form
|
|||||||
HandleStreamStarted(ev);
|
HandleStreamStarted(ev);
|
||||||
break;
|
break;
|
||||||
case VcEventType.StreamStopped:
|
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)
|
stUser.ChannelId == _currentChannelId)
|
||||||
AddActivity($"{stUser.Nickname} stopped a stream");
|
AddActivity($"{stUser.Nickname} stopped a stream");
|
||||||
break;
|
break;
|
||||||
|
case VcEventType.VoiceState:
|
||||||
|
HandleVoiceState(ev);
|
||||||
|
break;
|
||||||
case VcEventType.Disconnected:
|
case VcEventType.Disconnected:
|
||||||
HandleDisconnected(ev);
|
HandleDisconnected(ev);
|
||||||
break;
|
break;
|
||||||
@@ -316,12 +389,16 @@ public partial class MainForm : Form
|
|||||||
private void HandleUserJoined(VoiceCatEvent ev)
|
private void HandleUserJoined(VoiceCatEvent ev)
|
||||||
{
|
{
|
||||||
var user = new UserInfo(ev.UserId, ev.Text ?? $"User#{ev.UserId}", false, ev.ChannelId,
|
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;
|
_users[ev.UserId] = user;
|
||||||
RefreshChannelTree();
|
RefreshChannelTree();
|
||||||
RefreshUserList();
|
RefreshUserList();
|
||||||
if (ev.ChannelId == _currentChannelId && ev.UserId != _selfUserId)
|
if (ev.ChannelId == _currentChannelId && ev.UserId != _selfUserId)
|
||||||
|
{
|
||||||
AddActivity($"{user.Nickname} joined the channel");
|
AddActivity($"{user.Nickname} joined the channel");
|
||||||
|
_feedback.PlaySound(SoundEvent.ChannelJoin);
|
||||||
|
_feedback.Speak($"{user.Nickname} joined");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleUserLeft(VoiceCatEvent ev)
|
private void HandleUserLeft(VoiceCatEvent ev)
|
||||||
@@ -332,7 +409,12 @@ public partial class MainForm : Form
|
|||||||
_talkingUsers.Remove(ev.UserId);
|
_talkingUsers.Remove(ev.UserId);
|
||||||
RefreshChannelTree();
|
RefreshChannelTree();
|
||||||
RefreshUserList();
|
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))
|
if (_pmWindows.TryGetValue(ev.UserId, out var pmWin))
|
||||||
pmWin.AppendActivity($"{user.Nickname} disconnected from server");
|
pmWin.AppendActivity($"{user.Nickname} disconnected from server");
|
||||||
}
|
}
|
||||||
@@ -388,18 +470,23 @@ public partial class MainForm : Form
|
|||||||
.LocalDateTime.ToString("HH:mm")
|
.LocalDateTime.ToString("HH:mm")
|
||||||
: DateTime.Now.ToString("HH:mm");
|
: DateTime.Now.ToString("HH:mm");
|
||||||
string sender = GetNickname(ev.UserId);
|
string sender = GetNickname(ev.UserId);
|
||||||
|
bool isSelf = ev.UserId == _selfUserId;
|
||||||
|
string body = ev.Text ?? "";
|
||||||
|
|
||||||
if (ev.TextScope == VcTextScope.Private)
|
if (ev.TextScope == VcTextScope.Private)
|
||||||
{
|
{
|
||||||
// For our own outgoing PM, ev.ChannelId carries the recipient user ID.
|
// 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);
|
var win = GetOrOpenPmWindow(otherUserId);
|
||||||
bool isSelf = ev.UserId == _selfUserId;
|
win.AppendMessage(time, isSelf, sender, body);
|
||||||
win.AppendMessage(time, isSelf, sender, ev.Text ?? "");
|
_feedback.PlaySound(isSelf ? SoundEvent.PmSent : SoundEvent.PmRecv);
|
||||||
|
if (!isSelf) _feedback.Speak($"Private message from {sender}: {body}");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AppendChat(time, sender, ev.Text ?? "");
|
AppendChat(time, sender, body);
|
||||||
|
_feedback.PlaySound(isSelf ? SoundEvent.ChannelSent : SoundEvent.ChannelRecv);
|
||||||
|
if (!isSelf) _feedback.Speak($"{sender}: {body}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,6 +496,8 @@ public partial class MainForm : Form
|
|||||||
if (talking) _talkingUsers.Add(ev.UserId);
|
if (talking) _talkingUsers.Add(ev.UserId);
|
||||||
else _talkingUsers.Remove(ev.UserId);
|
else _talkingUsers.Remove(ev.UserId);
|
||||||
RefreshUserList();
|
RefreshUserList();
|
||||||
|
if (ev.UserId == _selfUserId)
|
||||||
|
_feedback.PlaySound(talking ? SoundEvent.VaStart : SoundEvent.VaStop);
|
||||||
if (talking && ev.UserId != _selfUserId &&
|
if (talking && ev.UserId != _selfUserId &&
|
||||||
_users.TryGetValue(ev.UserId, out var tUser) &&
|
_users.TryGetValue(ev.UserId, out var tUser) &&
|
||||||
tUser.ChannelId == _currentChannelId)
|
tUser.ChannelId == _currentChannelId)
|
||||||
@@ -437,6 +526,16 @@ public partial class MainForm : Form
|
|||||||
: $"Disconnected: {ev.Text}";
|
: $"Disconnected: {ev.Text}";
|
||||||
lblStatus.Text = msg;
|
lblStatus.Text = msg;
|
||||||
AddActivity(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();
|
tvChannels.Nodes.Clear();
|
||||||
lstUsers.Items.Clear();
|
lstUsers.Items.Clear();
|
||||||
_users.Clear();
|
_users.Clear();
|
||||||
@@ -445,6 +544,7 @@ public partial class MainForm : Form
|
|||||||
_micStreamId = 0;
|
_micStreamId = 0;
|
||||||
_screenStreamId = 0;
|
_screenStreamId = 0;
|
||||||
_screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null;
|
_screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null;
|
||||||
|
DisposeAuxCapture(); _auxStreamId = 0; // connection gone — drop capture, no StopStream
|
||||||
txtCompose.Enabled = false;
|
txtCompose.Enabled = false;
|
||||||
btnSend.Enabled = false;
|
btnSend.Enabled = false;
|
||||||
tsbJoinVoice.Enabled = false;
|
tsbJoinVoice.Enabled = false;
|
||||||
@@ -468,6 +568,7 @@ public partial class MainForm : Form
|
|||||||
private void RefreshChannelTree()
|
private void RefreshChannelTree()
|
||||||
{
|
{
|
||||||
uint toSelect = tvChannels.SelectedNode?.Tag is uint s ? s : _currentChannelId;
|
uint toSelect = tvChannels.SelectedNode?.Tag is uint s ? s : _currentChannelId;
|
||||||
|
bool hadFocus = tvChannels.Focused;
|
||||||
tvChannels.BeginUpdate();
|
tvChannels.BeginUpdate();
|
||||||
tvChannels.Nodes.Clear();
|
tvChannels.Nodes.Clear();
|
||||||
|
|
||||||
@@ -493,6 +594,18 @@ public partial class MainForm : Form
|
|||||||
tvChannels.ExpandAll();
|
tvChannels.ExpandAll();
|
||||||
SeekAndSelect(tvChannels.Nodes, toSelect);
|
SeekAndSelect(tvChannels.Nodes, toSelect);
|
||||||
tvChannels.EndUpdate();
|
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)
|
private bool SeekAndSelect(TreeNodeCollection nodes, uint channelId)
|
||||||
@@ -508,6 +621,11 @@ public partial class MainForm : Form
|
|||||||
|
|
||||||
private void RefreshUserList()
|
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.BeginUpdate();
|
||||||
lstUsers.Items.Clear();
|
lstUsers.Items.Clear();
|
||||||
foreach (var user in _users.Values
|
foreach (var user in _users.Values
|
||||||
@@ -521,6 +639,15 @@ public partial class MainForm : Form
|
|||||||
if (user.SelfDeafened || user.ServerDeafened) label += " (deafened)";
|
if (user.SelfDeafened || user.ServerDeafened) label += " (deafened)";
|
||||||
lstUsers.Items.Add(new UserListItem(user.Id, label));
|
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();
|
lstUsers.EndUpdate();
|
||||||
UpdateStatusLabel();
|
UpdateStatusLabel();
|
||||||
}
|
}
|
||||||
@@ -542,68 +669,22 @@ public partial class MainForm : Form
|
|||||||
lblStatus.Text = $"Connected as {_nickname}{suffix} — {chanName} ({count} user{(count == 1 ? "" : "s")})";
|
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 ────────────────────────────────────────────────────────
|
// ── Voice controls ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void BtnMicToggle_Click(object? sender, EventArgs e)
|
private void BtnMicToggle_Click(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (_micStreamId == 0)
|
if (_micStreamId == 0)
|
||||||
{
|
{
|
||||||
var (result, streamId) = _client.StartStream(VcStreamKind.Mic, "Microphone");
|
var result = _client.JoinVoice();
|
||||||
if (result == VcResult.Ok)
|
if (result != VcResult.Ok)
|
||||||
{
|
AddActivity($"Failed to join voice: {result}");
|
||||||
_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}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
StopAuxStream();
|
||||||
|
if (_screenStreamId != 0) StopScreenAudio();
|
||||||
_client.SetPushToTalk(false);
|
_client.SetPushToTalk(false);
|
||||||
_client.StopStream(_micStreamId);
|
_client.LeaveVoice();
|
||||||
_micStreamId = 0;
|
|
||||||
pbLevel.Value = 0;
|
|
||||||
SetVoiceJoinedState(false);
|
|
||||||
AddActivity("Left voice");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,9 +694,42 @@ public partial class MainForm : Form
|
|||||||
_miJoinVoice.Text = joined ? "Leave &Voice" : "&Join Voice";
|
_miJoinVoice.Text = joined ? "Leave &Voice" : "&Join Voice";
|
||||||
chkMute.Enabled = joined;
|
chkMute.Enabled = joined;
|
||||||
chkDeafen.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() =>
|
private void ApplySelfMute() =>
|
||||||
@@ -640,7 +754,7 @@ public partial class MainForm : Form
|
|||||||
|
|
||||||
var scope = picker.ChosenScope;
|
var scope = picker.ChosenScope;
|
||||||
|
|
||||||
if (scope is EntireDesktop)
|
if (scope is EntireDesktop { ExcludeSelf: false })
|
||||||
{
|
{
|
||||||
// Existing whole-device WASAPI loopback path — core handles it.
|
// Existing whole-device WASAPI loopback path — core handles it.
|
||||||
var (result, streamId) = _client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
|
var (result, streamId) = _client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
|
||||||
@@ -654,7 +768,8 @@ public partial class MainForm : Form
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Per-app path: suppress core loopback, C# mixer feeds PCM.
|
// 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");
|
var (result, streamId) = _client.StartStreamExternalFeed(VcStreamKind.ScreenAudio, "App audio");
|
||||||
if (result != VcResult.Ok)
|
if (result != VcResult.Ok)
|
||||||
{
|
{
|
||||||
@@ -664,12 +779,13 @@ public partial class MainForm : Form
|
|||||||
_screenStreamId = streamId;
|
_screenStreamId = streamId;
|
||||||
|
|
||||||
_screenMixer = new ProcessAudioMixer();
|
_screenMixer = new ProcessAudioMixer();
|
||||||
_screenMixer.Start(scope, _client, streamId, picker.VisibleApps);
|
_screenMixer.Start(scope, _client, streamId);
|
||||||
|
|
||||||
string desc = scope switch
|
string desc = scope switch
|
||||||
{
|
{
|
||||||
|
EntireDesktop => "entire desktop (excluding VoiceCat)",
|
||||||
OnlyApps o => $"only {o.Pids.Count} app(s)",
|
OnlyApps o => $"only {o.Pids.Count} app(s)",
|
||||||
AllExceptApps a => $"all except {a.Pids.Count} app(s)",
|
AllExceptApps a => $"all except {a.Names[0]}",
|
||||||
_ => "apps",
|
_ => "apps",
|
||||||
};
|
};
|
||||||
AddActivity($"Sharing screen audio: {desc}");
|
AddActivity($"Sharing screen audio: {desc}");
|
||||||
@@ -692,88 +808,103 @@ public partial class MainForm : Form
|
|||||||
AddActivity("Stopped sharing 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) =>
|
private void TrkOutputVolume_Scroll(object? sender, EventArgs e) =>
|
||||||
_client.SetOutputVolume(trkOutputVolume.Value / 100f);
|
_client.SetOutputVolume(trkOutputVolume.Value / 100f);
|
||||||
|
|
||||||
private void RadioVad_CheckedChanged(object? sender, EventArgs e)
|
private float VadThresholdFromSettings() =>
|
||||||
{
|
0.1f * (1f - (_voiceSettings.VadThresholdSlider - 1f) / 99f);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── PTT key handling (focus-scoped) ───────────────────────────────────────
|
// ── PTT key handling (focus-scoped) ───────────────────────────────────────
|
||||||
|
|
||||||
private void MainForm_KeyDown(object? sender, KeyEventArgs e)
|
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;
|
if (ActiveControl is TextBox or RichTextBox) return;
|
||||||
_client.SetPushToTalk(true);
|
_client.SetPushToTalk(true);
|
||||||
lblPttKey.Text = $"({_pttKey} ▶)";
|
if (!_pttEngaged) // first key-down only, not auto-repeat
|
||||||
e.Handled = true;
|
{
|
||||||
|
_pttEngaged = true;
|
||||||
|
_feedback.PlaySound(SoundEvent.Ptt);
|
||||||
|
}
|
||||||
|
e.Handled = e.SuppressKeyPress = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MainForm_HotkeyDown(object? sender, KeyEventArgs e)
|
private void MainForm_HotkeyDown(object? sender, KeyEventArgs e)
|
||||||
@@ -784,31 +915,120 @@ public partial class MainForm : Form
|
|||||||
{
|
{
|
||||||
case Keys.V:
|
case Keys.V:
|
||||||
BtnMicToggle_Click(null, EventArgs.Empty);
|
BtnMicToggle_Click(null, EventArgs.Empty);
|
||||||
e.Handled = true;
|
|
||||||
break;
|
break;
|
||||||
case Keys.S:
|
case Keys.S:
|
||||||
BtnScreenShareToggle_Click(null, EventArgs.Empty);
|
BtnScreenShareToggle_Click(null, EventArgs.Empty);
|
||||||
e.Handled = true;
|
|
||||||
break;
|
break;
|
||||||
case Keys.M:
|
case Keys.M:
|
||||||
chkMute.Checked = !chkMute.Checked;
|
chkMute.Checked = !chkMute.Checked;
|
||||||
ApplySelfMute();
|
ApplySelfMute();
|
||||||
e.Handled = true;
|
|
||||||
break;
|
break;
|
||||||
case Keys.D:
|
case Keys.D:
|
||||||
chkDeafen.Checked = !chkDeafen.Checked;
|
chkDeafen.Checked = !chkDeafen.Checked;
|
||||||
ApplySelfMute();
|
ApplySelfMute();
|
||||||
e.Handled = true;
|
|
||||||
break;
|
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)
|
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);
|
_client.SetPushToTalk(false);
|
||||||
lblPttKey.Text = $"({_pttKey})";
|
_pttEngaged = false;
|
||||||
e.Handled = true;
|
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 ────────────────────────────────────────────────────
|
// ── Channel navigation ────────────────────────────────────────────────────
|
||||||
@@ -858,13 +1078,17 @@ public partial class MainForm : Form
|
|||||||
win = new PrivateMessageForm(_client, userId, nick, _selfUserId);
|
win = new PrivateMessageForm(_client, userId, nick, _selfUserId);
|
||||||
win.FormClosed += (_, _) => _pmWindows.Remove(userId);
|
win.FormClosed += (_, _) => _pmWindows.Remove(userId);
|
||||||
_pmWindows[userId] = win;
|
_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
|
else
|
||||||
{
|
{
|
||||||
if (win.WindowState == FormWindowState.Minimized)
|
if (win.WindowState == FormWindowState.Minimized)
|
||||||
win.WindowState = FormWindowState.Normal;
|
win.WindowState = FormWindowState.Normal;
|
||||||
win.BringToFront();
|
// Raise it for this explicit user-initiated open without the owner-style focus trap.
|
||||||
|
win.Activate();
|
||||||
}
|
}
|
||||||
return win;
|
return win;
|
||||||
}
|
}
|
||||||
@@ -890,7 +1114,7 @@ public partial class MainForm : Form
|
|||||||
OpenPmWindow(dlg.SelectedUserId);
|
OpenPmWindow(dlg.SelectedUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M5: Moderation helpers ────────────────────────────────────────────────
|
// ── Moderation helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void UpdateSelfServerMuteState(bool muted, bool deafened)
|
private void UpdateSelfServerMuteState(bool muted, bool deafened)
|
||||||
{
|
{
|
||||||
@@ -922,8 +1146,8 @@ public partial class MainForm : Form
|
|||||||
|
|
||||||
var editInfo = new ChannelEditInfo(
|
var editInfo = new ChannelEditInfo(
|
||||||
channel.Id, channel.ParentId, channel.Name, channel.Topic,
|
channel.Id, channel.ParentId, channel.Name, channel.Topic,
|
||||||
channel.PasswordProtected, null, channel.MaxUsers, 0,
|
channel.PasswordProtected, null, channel.MaxUsers, channel.SortOrder,
|
||||||
new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10, false));
|
channel.Audio);
|
||||||
|
|
||||||
using var dlg = new ChannelEditDialog(_channels, editInfo);
|
using var dlg = new ChannelEditDialog(_channels, editInfo);
|
||||||
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
|
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
|
||||||
@@ -1067,9 +1291,11 @@ public partial class MainForm : Form
|
|||||||
foreach (var win in _pmWindows.Values.ToList()) win.Close();
|
foreach (var win in _pmWindows.Values.ToList()) win.Close();
|
||||||
_pmWindows.Clear();
|
_pmWindows.Clear();
|
||||||
if (_screenStreamId != 0) { _screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null; _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);
|
if (_micStreamId != 0) _client.StopStream(_micStreamId);
|
||||||
_client.Disconnect();
|
_client.Disconnect();
|
||||||
_client.Dispose();
|
_client.Dispose();
|
||||||
|
_feedback.Dispose();
|
||||||
base.OnFormClosed(e);
|
base.OnFormClosed(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
128
clients/windows/VoiceCat.App/Forms/NotificationSettingsForm.cs
Normal file
128
clients/windows/VoiceCat.App/Forms/NotificationSettingsForm.cs
Normal file
@@ -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;
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
/// <summary>Small modal for "type a password right now" — used when a saved server's
|
/// <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 partial class PasswordPromptDialog : Form
|
||||||
{
|
{
|
||||||
public string Password => txtPassword.Text;
|
public string Password => txtPassword.Text;
|
||||||
|
|||||||
@@ -117,11 +117,11 @@ public sealed class PerUserTuningDialog : Form
|
|||||||
var trkGain = new TrackBar
|
var trkGain = new TrackBar
|
||||||
{
|
{
|
||||||
AccessibleName = $"Gain for {s.Label}",
|
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),
|
Location = new Point(12, y + 20),
|
||||||
Size = new Size(260, 45),
|
Size = new Size(260, 45),
|
||||||
Minimum = 0,
|
Minimum = 0,
|
||||||
Maximum = 200,
|
Maximum = 400,
|
||||||
Value = ClampToTrack(gain0),
|
Value = ClampToTrack(gain0),
|
||||||
TickFrequency = 25,
|
TickFrequency = 25,
|
||||||
SmallChange = 5,
|
SmallChange = 5,
|
||||||
@@ -150,7 +150,7 @@ public sealed class PerUserTuningDialog : Form
|
|||||||
|
|
||||||
var chkNr = new CheckBox
|
var chkNr = new CheckBox
|
||||||
{
|
{
|
||||||
Text = "&Noise reduction (planned — currently passthrough)",
|
Text = "&Noise reduction",
|
||||||
AutoSize = true,
|
AutoSize = true,
|
||||||
Location = new Point(96, y + 48),
|
Location = new Point(96, y + 48),
|
||||||
Checked = nr0,
|
Checked = nr0,
|
||||||
@@ -186,7 +186,7 @@ public sealed class PerUserTuningDialog : Form
|
|||||||
private static int ClampToTrack(float gain)
|
private static int ClampToTrack(float gain)
|
||||||
{
|
{
|
||||||
int v = (int)Math.Round(gain * 100f);
|
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
|
private sealed class StreamRow
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ public sealed class PrivateMessageForm : Form
|
|||||||
ClientSize = new Size(480, 360);
|
ClientSize = new Size(480, 360);
|
||||||
MinimumSize = new Size(320, 240);
|
MinimumSize = new Size(320, 240);
|
||||||
StartPosition = FormStartPosition.Manual;
|
StartPosition = FormStartPosition.Manual;
|
||||||
|
KeyPreview = true;
|
||||||
|
KeyDown += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.KeyCode == Keys.Escape) { e.Handled = e.SuppressKeyPress = true; Close(); }
|
||||||
|
};
|
||||||
|
|
||||||
_rtbHistory = new RichTextBox
|
_rtbHistory = new RichTextBox
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using VoiceCat.Interop;
|
|||||||
namespace VoiceCat.App.Forms;
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
/// <summary>
|
/// <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
|
/// — 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
|
/// docs/security.md describes; showing a dialog on every routine reconnect would be exactly
|
||||||
/// the "overly chatty" experience this project avoids elsewhere too).
|
/// the "overly chatty" experience this project avoids elsewhere too).
|
||||||
|
|||||||
88
clients/windows/VoiceCat.App/Models/VoiceSettings.cs
Normal file
88
clients/windows/VoiceCat.App/Models/VoiceSettings.cs
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
147
clients/windows/VoiceCat.App/Native/RawInput.cs
Normal file
147
clients/windows/VoiceCat.App/Native/RawInput.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
42
clients/windows/VoiceCat.App/Notifications/EventFeedback.cs
Normal file
42
clients/windows/VoiceCat.App/Notifications/EventFeedback.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
namespace VoiceCat.App.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Combines the sound pool and the speech announcer behind the user's <see cref="FeedbackSettings"/>.
|
||||||
|
/// MainForm calls <see cref="PlaySound"/> / <see cref="Speak"/> from inside its existing event
|
||||||
|
/// handlers (which already resolve nicknames and channel membership), so this type only owns the
|
||||||
|
/// "should I, and how" policy — not the event routing.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EventFeedback : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SoundPlayerPool _sounds = new();
|
||||||
|
private readonly SpeechAnnouncer _speech = new();
|
||||||
|
|
||||||
|
public FeedbackSettings Settings { get; set; }
|
||||||
|
|
||||||
|
public EventFeedback(FeedbackSettings settings) => Settings = settings;
|
||||||
|
|
||||||
|
/// <summary>True when speech is both enabled and actually available on this machine.</summary>
|
||||||
|
public bool SpeechAvailable => _speech.Available;
|
||||||
|
|
||||||
|
public void PlaySound(SoundEvent ev)
|
||||||
|
{
|
||||||
|
if (!Settings.Sounds || Settings.Volume <= 0f) return;
|
||||||
|
// The two opt-in categories are gated by their own flags.
|
||||||
|
if ((ev is SoundEvent.VaStart or SoundEvent.VaStop) && !Settings.SelfTalkSounds) return;
|
||||||
|
if (ev is SoundEvent.Ptt && !Settings.PttSound) return;
|
||||||
|
_sounds.Play(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Announce <paramref name="text"/> when spoken feedback is enabled.</summary>
|
||||||
|
public void Speak(string text)
|
||||||
|
{
|
||||||
|
if (!Settings.Speech) return;
|
||||||
|
_speech.Speak(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_sounds.Dispose();
|
||||||
|
_speech.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User preferences for event sounds and spoken (text-to-speech) feedback. Persisted to
|
||||||
|
/// %AppData%\VoiceCat\feedback.json — same pattern as <see cref="Models.ServerListStore"/>:
|
||||||
|
/// a missing or corrupt file yields defaults rather than throwing.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FeedbackSettings
|
||||||
|
{
|
||||||
|
/// <summary>Master switch for event sound effects.</summary>
|
||||||
|
public bool Sounds { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Master switch for spoken announcements (Prismatoid TTS). Off by default.</summary>
|
||||||
|
public bool Speech { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>Sound-effects volume, 0..1. 0 mutes effects (SoundPlayer has no gain control,
|
||||||
|
/// so this is honoured as a mute gate — see <see cref="SoundPlayerPool"/>).</summary>
|
||||||
|
public float Volume { get; set; } = 1.0f;
|
||||||
|
|
||||||
|
/// <summary>Play va_start/va_stop for your own voice-activity transitions. Off by default
|
||||||
|
/// (fires on every utterance — noisy).</summary>
|
||||||
|
public bool SelfTalkSounds { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>Play the ptt cue when push-to-talk is engaged. Off by default.</summary>
|
||||||
|
public bool PttSound { get; set; } = false;
|
||||||
|
|
||||||
|
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, "feedback.json");
|
||||||
|
|
||||||
|
public static FeedbackSettings Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(FilePath)) return new FeedbackSettings();
|
||||||
|
string json = File.ReadAllText(FilePath);
|
||||||
|
return JsonSerializer.Deserialize<FeedbackSettings>(json) ?? new FeedbackSettings();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new FeedbackSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(AppDataDir);
|
||||||
|
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
47
clients/windows/VoiceCat.App/Notifications/SoundEvent.cs
Normal file
47
clients/windows/VoiceCat.App/Notifications/SoundEvent.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
namespace VoiceCat.App.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The set of audible event cues. Each maps to a WAV in the app's <c>sounds\</c> folder
|
||||||
|
/// (copied from <c>assets/sounds/</c> at build time). The same logical set is mirrored in the
|
||||||
|
/// macOS/iOS clients so feedback stays consistent across platforms.
|
||||||
|
/// </summary>
|
||||||
|
public enum SoundEvent
|
||||||
|
{
|
||||||
|
ChannelJoin,
|
||||||
|
ChannelLeave,
|
||||||
|
ChannelRecv, // channel text message from someone else
|
||||||
|
ChannelSent, // channel text message I sent
|
||||||
|
PmRecv,
|
||||||
|
PmSent,
|
||||||
|
Login,
|
||||||
|
Logout,
|
||||||
|
ConnectionLost,
|
||||||
|
VoiceOn,
|
||||||
|
VoiceOff,
|
||||||
|
VaStart, // my voice-activity began (off by default)
|
||||||
|
VaStop, // my voice-activity ended (off by default)
|
||||||
|
Ptt, // push-to-talk engaged (off by default)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class SoundEventExtensions
|
||||||
|
{
|
||||||
|
/// <summary>WAV file name (without directory) for each event, matching assets/sounds/.</summary>
|
||||||
|
public static string FileName(this SoundEvent ev) => ev switch
|
||||||
|
{
|
||||||
|
SoundEvent.ChannelJoin => "channel_join.wav",
|
||||||
|
SoundEvent.ChannelLeave => "channel_leave.wav",
|
||||||
|
SoundEvent.ChannelRecv => "channel_recv.wav",
|
||||||
|
SoundEvent.ChannelSent => "channel_sent.wav",
|
||||||
|
SoundEvent.PmRecv => "pm_recv.wav",
|
||||||
|
SoundEvent.PmSent => "pm_sent.wav",
|
||||||
|
SoundEvent.Login => "login.wav",
|
||||||
|
SoundEvent.Logout => "logout.wav",
|
||||||
|
SoundEvent.ConnectionLost => "connection_lost.wav",
|
||||||
|
SoundEvent.VoiceOn => "voice_on.wav",
|
||||||
|
SoundEvent.VoiceOff => "voice_off.wav",
|
||||||
|
SoundEvent.VaStart => "va_start.wav",
|
||||||
|
SoundEvent.VaStop => "va_stop.wav",
|
||||||
|
SoundEvent.Ptt => "ptt.wav",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(ev), ev, null),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Media;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays short event cues from the app's <c>sounds\</c> folder. Each WAV is loaded once into a
|
||||||
|
/// cached <see cref="SoundPlayer"/> (built into the Windows Desktop framework — no extra
|
||||||
|
/// dependency). <see cref="Play"/> is non-blocking (<see cref="SoundPlayer.Play"/> renders on a
|
||||||
|
/// background thread).
|
||||||
|
///
|
||||||
|
/// Note: <see cref="SoundPlayer"/> exposes no gain control, so volume is honoured only as a mute
|
||||||
|
/// gate (volume == 0 → silent). If finer control or reliable overlapping playback is needed
|
||||||
|
/// later, swap this for NAudio.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SoundPlayerPool : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly string SoundsDir =
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "sounds");
|
||||||
|
|
||||||
|
private readonly Dictionary<SoundEvent, SoundPlayer?> _players = [];
|
||||||
|
|
||||||
|
/// <summary>Resolve, load and cache the player for an event. Returns null if the file is
|
||||||
|
/// missing or fails to load — playback then silently no-ops.</summary>
|
||||||
|
private SoundPlayer? Get(SoundEvent ev)
|
||||||
|
{
|
||||||
|
if (_players.TryGetValue(ev, out var cached)) return cached;
|
||||||
|
|
||||||
|
SoundPlayer? player = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string path = Path.Combine(SoundsDir, ev.FileName());
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
player = new SoundPlayer(path);
|
||||||
|
player.Load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
player = null; // unreadable/invalid WAV — degrade to silence
|
||||||
|
}
|
||||||
|
|
||||||
|
_players[ev] = player;
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Play(SoundEvent ev)
|
||||||
|
{
|
||||||
|
try { Get(ev)?.Play(); }
|
||||||
|
catch { /* never let a notification sound surface as an error */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var p in _players.Values) p?.Dispose();
|
||||||
|
_players.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using Prismatoid;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spoken event announcements via Prismatoid (bindings for the Prism speech library —
|
||||||
|
/// integrates with the active screen reader / system speech, no extra runtime deps).
|
||||||
|
///
|
||||||
|
/// All construction and calls are wrapped so a machine with no available speech backend simply
|
||||||
|
/// degrades to silence rather than crashing the app.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpeechAnnouncer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly PrismContext? _context;
|
||||||
|
private readonly object? _backend; // SpeechBackend; held as object to keep this resilient
|
||||||
|
|
||||||
|
public SpeechAnnouncer()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_context = new PrismContext();
|
||||||
|
_backend = _context.AcquireBestBackend();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_context?.Dispose();
|
||||||
|
_context = null;
|
||||||
|
_backend = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when a speech backend is available on this machine.</summary>
|
||||||
|
public bool Available => _backend is not null;
|
||||||
|
|
||||||
|
/// <summary>Speak <paramref name="text"/>. Queued (does not interrupt prior speech) so a
|
||||||
|
/// burst of events is read in order.</summary>
|
||||||
|
public void Speak(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_backend is { } b)
|
||||||
|
((dynamic)b).Speak(text, interrupt: false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* never surface a speech failure */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { (_backend as IDisposable)?.Dispose(); } catch { /* ignore */ }
|
||||||
|
_context?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,30 +7,21 @@ internal static class Program
|
|||||||
[STAThread]
|
[STAThread]
|
||||||
private static void Main()
|
private static void Main()
|
||||||
{
|
{
|
||||||
// Diagnostic-logging-only for now (manual debugging session) — every exception that
|
// Surface exceptions that WinForms' default message-loop handling would otherwise
|
||||||
// would otherwise be silently caught by WinForms' default message-loop handling (or
|
// swallow silently (or crash with no visible cause).
|
||||||
// crash with no visible cause) gets printed to stdout/stderr first.
|
|
||||||
Application.ThreadException += (_, e) =>
|
Application.ThreadException += (_, e) =>
|
||||||
Console.Error.WriteLine($"[UNHANDLED ThreadException] {e.Exception}");
|
Console.Error.WriteLine($"[UNHANDLED ThreadException] {e.Exception}");
|
||||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||||
Console.Error.WriteLine($"[UNHANDLED AppDomain exception] {e.ExceptionObject}");
|
Console.Error.WriteLine($"[UNHANDLED AppDomain exception] {e.ExceptionObject}");
|
||||||
|
|
||||||
Console.WriteLine("VoiceCat.App starting...");
|
|
||||||
ApplicationConfiguration.Initialize();
|
ApplicationConfiguration.Initialize();
|
||||||
|
|
||||||
using var connectDialog = new ConnectDialog();
|
using var connectDialog = new ConnectDialog();
|
||||||
Console.WriteLine("Showing ConnectDialog...");
|
|
||||||
var result = connectDialog.ShowDialog();
|
var result = connectDialog.ShowDialog();
|
||||||
Console.WriteLine($"ConnectDialog closed with DialogResult={result}, ConnectedClient={(connectDialog.ConnectedClient is null ? "null" : "set")}");
|
|
||||||
if (result != DialogResult.OK || connectDialog.ConnectedClient is null)
|
if (result != DialogResult.OK || connectDialog.ConnectedClient is null)
|
||||||
{
|
|
||||||
Console.WriteLine("Exiting (cancelled or no connected client).");
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Launching MainForm...");
|
|
||||||
Application.Run(new MainForm(connectDialog.ConnectedClient, connectDialog.SelfUserId,
|
Application.Run(new MainForm(connectDialog.ConnectedClient, connectDialog.SelfUserId,
|
||||||
connectDialog.Nickname));
|
connectDialog.Nickname, connectDialog.ServerName));
|
||||||
Console.WriteLine("MainForm closed. Exiting.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,21 @@
|
|||||||
<ProjectReference Include="..\VoiceCat.Interop\VoiceCat.Interop.csproj" />
|
<ProjectReference Include="..\VoiceCat.Interop\VoiceCat.Interop.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Prismatoid — .NET bindings for the Prism speech library, used for spoken event
|
||||||
|
announcements (text-to-speech). net10.0, no transitive dependencies. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Prismatoid" Version="0.3.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Event-cue WAVs (shared with the macOS/iOS clients) copied into sounds\ next to the exe;
|
||||||
|
SoundPlayerPool loads them from AppContext.BaseDirectory\sounds. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\..\..\assets\sounds\*.wav">
|
||||||
|
<Link>sounds\%(Filename)%(Extension)</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net10.0-windows</TargetFramework>
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
|
|||||||
_tempDir = Path.Combine(Path.GetTempPath(), "vc_csharp_smoke_" + Guid.NewGuid().ToString("N"));
|
_tempDir = Path.Combine(Path.GetTempPath(), "vc_csharp_smoke_" + Guid.NewGuid().ToString("N"));
|
||||||
Directory.CreateDirectory(_tempDir);
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
|
||||||
string serverExe = Path.Combine(FindRepoRoot(), "build", "m1-dev", "bin", "voicecat-server.exe");
|
string serverExe = Path.Combine(FindRepoRoot(), "build", "dev", "bin", "voicecat-server.exe");
|
||||||
Assert.True(File.Exists(serverExe),
|
Assert.True(File.Exists(serverExe),
|
||||||
$"voicecat-server.exe not found at '{serverExe}' — build the m1-dev preset first " +
|
$"voicecat-server.exe not found at '{serverExe}' — build the dev preset first " +
|
||||||
"(cmake --preset m1-dev && cmake --build --preset m1-dev).");
|
"(cmake --preset dev && cmake --build --preset dev).");
|
||||||
|
|
||||||
var psi = new ProcessStartInfo(serverExe)
|
var psi = new ProcessStartInfo(serverExe)
|
||||||
{
|
{
|
||||||
@@ -56,9 +56,9 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
|
|||||||
Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s.");
|
Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s.");
|
||||||
_port = port!.Value;
|
_port = port!.Value;
|
||||||
|
|
||||||
// M5: provision a known admin account so we can exercise moderation wrappers end-to-end.
|
// Provision a known admin account so we can exercise moderation wrappers end-to-end.
|
||||||
string adminExe = Path.Combine(FindRepoRoot(), "build", "m1-dev", "bin", "voicecat-admin.exe");
|
string adminExe = Path.Combine(FindRepoRoot(), "build", "dev", "bin", "voicecat-admin.exe");
|
||||||
Assert.True(File.Exists(adminExe), "voicecat-admin.exe not found — build the m1-dev preset.");
|
Assert.True(File.Exists(adminExe), "voicecat-admin.exe not found — build the dev preset.");
|
||||||
var adminPsi = new ProcessStartInfo(adminExe)
|
var adminPsi = new ProcessStartInfo(adminExe)
|
||||||
{
|
{
|
||||||
Arguments = $"--data-dir \"{_tempDir}\" account add admin2 --admin --password testpassword123",
|
Arguments = $"--data-dir \"{_tempDir}\" account add admin2 --admin --password testpassword123",
|
||||||
@@ -140,14 +140,14 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
|
|||||||
var channels = client.ListChannels();
|
var channels = client.ListChannels();
|
||||||
Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby");
|
Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby");
|
||||||
|
|
||||||
// M5: permissions getter round-trip.
|
// Permissions getter round-trip.
|
||||||
var perms = client.GetPermissions();
|
var perms = client.GetPermissions();
|
||||||
Assert.False(perms.IsAdmin);
|
Assert.False(perms.IsAdmin);
|
||||||
Assert.False(perms.CanKick);
|
Assert.False(perms.CanKick);
|
||||||
|
|
||||||
// M5: moderation request wrappers queue without error. As a guest, account listing
|
// Moderation request wrappers queue without error. As a guest, account listing
|
||||||
// is rejected by the server with a GenericResult, which proves the wrapper path works
|
// is rejected by the server with a GenericResult, which proves the wrapper path works
|
||||||
// end-to-end and that the new event type is delivered through P/Invoke.
|
// end-to-end and that the event type is delivered through P/Invoke.
|
||||||
Assert.Equal(VcResult.Ok, client.RequestAccountList());
|
Assert.Equal(VcResult.Ok, client.RequestAccountList());
|
||||||
Assert.True(PumpUntil(client,
|
Assert.True(PumpUntil(client,
|
||||||
() => events.Any(e => e.Type == VcEventType.GenericResult), 3000),
|
() => events.Any(e => e.Type == VcEventType.GenericResult), 3000),
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public enum VcConnectionState
|
|||||||
TlsHandshake = 2,
|
TlsHandshake = 2,
|
||||||
Authenticating = 3,
|
Authenticating = 3,
|
||||||
Connected = 4,
|
Connected = 4,
|
||||||
/// <summary>M4: handshake succeeded, waiting on vc_confirm_server_identity().</summary>
|
/// <summary>Handshake succeeded, waiting on vc_confirm_server_identity().</summary>
|
||||||
VerifyingIdentity = 5,
|
VerifyingIdentity = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,14 +84,16 @@ public enum VcEventType
|
|||||||
TalkState = 9,
|
TalkState = 9,
|
||||||
Error = 10,
|
Error = 10,
|
||||||
Disconnected = 11,
|
Disconnected = 11,
|
||||||
/// <summary>M4: reply to VoiceCatClient.JoinChannelAsync's underlying vc_join_channel.</summary>
|
/// <summary>Reply to VoiceCatClient.JoinChannelAsync's underlying vc_join_channel.</summary>
|
||||||
JoinResult = 12,
|
JoinResult = 12,
|
||||||
/// <summary>M4: the TOFU server-identity gate — see VcTofuStatus.</summary>
|
/// <summary>The TOFU server-identity gate — see VcTofuStatus.</summary>
|
||||||
ServerIdentity = 13,
|
ServerIdentity = 13,
|
||||||
/// <summary>M5: async result for moderation/admin/channel operations.</summary>
|
/// <summary>Async result for moderation/admin/channel operations.</summary>
|
||||||
GenericResult = 14,
|
GenericResult = 14,
|
||||||
/// <summary>M5: reply to VoiceCatClient.RequestAccountList — call ListAccounts() to read.</summary>
|
/// <summary>Reply to VoiceCatClient.RequestAccountList — call ListAccounts() to read.</summary>
|
||||||
AccountList = 15,
|
AccountList = 15,
|
||||||
|
/// <summary>Voice-plane subscription state. u32a = 1 (subscribed) or 0 (unsubscribed).</summary>
|
||||||
|
VoiceState = 16,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ internal static class Marshaling
|
|||||||
Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty,
|
Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty,
|
||||||
Marshal.PtrToStringUTF8(raw.Topic) ?? string.Empty,
|
Marshal.PtrToStringUTF8(raw.Topic) ?? string.Empty,
|
||||||
raw.PasswordProtected != 0,
|
raw.PasswordProtected != 0,
|
||||||
raw.MaxUsers));
|
raw.MaxUsers,
|
||||||
|
raw.SortOrder,
|
||||||
|
ToManaged(in raw.Audio)));
|
||||||
}
|
}
|
||||||
NativeMethods.vc_free_channel_list(ref native);
|
NativeMethods.vc_free_channel_list(ref native);
|
||||||
return result;
|
return result;
|
||||||
@@ -59,7 +61,8 @@ internal static class Marshaling
|
|||||||
raw.SelfMicMuted != 0,
|
raw.SelfMicMuted != 0,
|
||||||
raw.SelfDeafened != 0,
|
raw.SelfDeafened != 0,
|
||||||
raw.ServerMuted != 0,
|
raw.ServerMuted != 0,
|
||||||
raw.ServerDeafened != 0));
|
raw.ServerDeafened != 0,
|
||||||
|
raw.VoiceSubscribed != 0));
|
||||||
}
|
}
|
||||||
NativeMethods.vc_free_user_list(ref native);
|
NativeMethods.vc_free_user_list(ref native);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ public sealed record ChannelInfo(
|
|||||||
string Name,
|
string Name,
|
||||||
string Topic,
|
string Topic,
|
||||||
bool PasswordProtected,
|
bool PasswordProtected,
|
||||||
uint MaxUsers);
|
uint MaxUsers,
|
||||||
|
uint SortOrder,
|
||||||
|
AudioConfigInfo Audio);
|
||||||
|
|
||||||
public sealed record ChannelEditInfo(
|
public sealed record ChannelEditInfo(
|
||||||
uint Id,
|
uint Id,
|
||||||
@@ -30,7 +32,8 @@ public sealed record UserInfo(
|
|||||||
bool SelfMicMuted,
|
bool SelfMicMuted,
|
||||||
bool SelfDeafened,
|
bool SelfDeafened,
|
||||||
bool ServerMuted,
|
bool ServerMuted,
|
||||||
bool ServerDeafened);
|
bool ServerDeafened,
|
||||||
|
bool VoiceSubscribed);
|
||||||
|
|
||||||
public sealed record PermissionsInfo(
|
public sealed record PermissionsInfo(
|
||||||
bool CanCreateTempChannel,
|
bool CanCreateTempChannel,
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ internal static partial class NativeMethods
|
|||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial VcResult vc_leave_channel(nint c);
|
internal static partial VcResult vc_leave_channel(nint c);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
internal static partial VcResult vc_join_voice(nint c);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
internal static partial VcResult vc_leave_voice(nint c);
|
||||||
|
|
||||||
// ── Local media streams ─────────────────────────────────────────────────────────────────
|
// ── Local media streams ─────────────────────────────────────────────────────────────────
|
||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial VcResult vc_stream_start(nint c, in VcStreamDescNative desc,
|
internal static partial VcResult vc_stream_start(nint c, in VcStreamDescNative desc,
|
||||||
@@ -65,6 +71,12 @@ internal static partial class NativeMethods
|
|||||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||||
internal static partial VcResult vc_set_input_device(nint c, uint streamId, string? deviceId);
|
internal static partial VcResult vc_set_input_device(nint c, uint streamId, string? deviceId);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
internal static partial VcResult vc_set_capture_channels(nint c, uint streamId, uint channels);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
internal static partial VcResult vc_audio_restart(nint c);
|
||||||
|
|
||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial VcResult vc_set_input_mode(nint c, VcInputMode mode);
|
internal static partial VcResult vc_set_input_mode(nint c, VcInputMode mode);
|
||||||
|
|
||||||
@@ -80,6 +92,12 @@ internal static partial class NativeMethods
|
|||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial VcResult vc_set_output_volume(nint c, float gain);
|
internal static partial VcResult vc_set_output_volume(nint c, float gain);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
internal static partial VcResult vc_set_input_gain(nint c, float gain);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
internal static partial VcResult vc_set_input_noise_reduction(nint c, int enable);
|
||||||
|
|
||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId,
|
internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId,
|
||||||
float gain, int muted, int noiseReduction);
|
float gain, int muted, int noiseReduction);
|
||||||
@@ -130,7 +148,7 @@ internal static partial class NativeMethods
|
|||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial void vc_free_device_list(ref VcDeviceListNative list);
|
internal static partial void vc_free_device_list(ref VcDeviceListNative list);
|
||||||
|
|
||||||
// ── M4: channel / user / stream snapshot getters ────────────────────────────────────────
|
// ── Channel / user / stream snapshot getters ────────────────────────────────────────────
|
||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial VcResult vc_list_channels(nint c, out VcChannelListNative outList);
|
internal static partial VcResult vc_list_channels(nint c, out VcChannelListNative outList);
|
||||||
|
|
||||||
@@ -150,7 +168,7 @@ internal static partial class NativeMethods
|
|||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial void vc_free_stream_summary_list(ref VcStreamSummaryListNative list);
|
internal static partial void vc_free_stream_summary_list(ref VcStreamSummaryListNative list);
|
||||||
|
|
||||||
// ── M4: TOFU server-identity gate ───────────────────────────────────────────────────────
|
// ── TOFU server-identity gate ───────────────────────────────────────────────────────────
|
||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial VcResult vc_confirm_server_identity(nint c, int accept);
|
internal static partial VcResult vc_confirm_server_identity(nint c, int accept);
|
||||||
|
|
||||||
@@ -158,7 +176,7 @@ internal static partial class NativeMethods
|
|||||||
internal static partial VcResult vc_get_server_identity_display(nint c, nint outBuf,
|
internal static partial VcResult vc_get_server_identity_display(nint c, nint outBuf,
|
||||||
nuint bufCap, out nuint outLen);
|
nuint bufCap, out nuint outLen);
|
||||||
|
|
||||||
// ── M5: Moderation & admin ─────────────────────────────────────────────────────────────
|
// ── Moderation & admin ───────────────────────────────────────────────────────────────────
|
||||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||||
internal static partial VcResult vc_kick_user(nint c, uint userId, string? reason);
|
internal static partial VcResult vc_kick_user(nint c, uint userId, string? reason);
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user