Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
370dd37a22 | ||
|
|
523eafd4d7 | ||
|
|
6c00fae1cf | ||
|
|
11a2541042 | ||
|
|
fe3da41e41 | ||
|
|
602eedc029 | ||
|
|
020218a7a1 | ||
|
|
1e774035d1 | ||
|
|
acc1450904 | ||
|
|
72ded4e011 | ||
|
|
c04aaf4a51 | ||
|
|
c4b8732bd7 | ||
|
|
7968f90f7d | ||
|
|
508fa8b408 | ||
|
|
00e3fa7ad5 | ||
|
|
ef19f4e7ce | ||
|
|
7ab50a4eb5 | ||
|
|
0c8efa38f1 | ||
|
|
af823da543 | ||
|
|
b944bd89d7 | ||
|
|
475f6e0603 | ||
|
|
008128defc | ||
|
|
dd6fa6121e | ||
|
|
8dbe767d4f | ||
|
|
f509c370b3 | ||
|
|
2948c029d0 | ||
|
|
40883c40c8 | ||
|
|
1efeaac19d | ||
|
|
6d2405f67a | ||
|
|
01a4a9ed28 | ||
|
|
93f4608250 | ||
|
|
e14dd73570 | ||
|
|
2ab8d5aae2 | ||
|
|
78ecafcc2c | ||
|
|
8c9eba15c3 | ||
|
|
292617f8e8 | ||
|
|
484522cbb6 | ||
|
|
ecdd68eff2 | ||
|
|
b2937da726 | ||
|
|
b20e6b663a | ||
|
|
13c7648a65 | ||
|
|
328776622f | ||
|
|
7dbf461262 | ||
|
|
3b13a7edb4 | ||
|
|
89bbfa1e18 | ||
|
|
0a6ef55937 | ||
|
|
aa796d7395 | ||
|
|
08d7ace25d | ||
|
|
ddd977796f | ||
|
|
2b285491d0 | ||
|
|
413f247378 | ||
|
|
3f9ea4f7b8 | ||
|
|
2f6d45fb04 | ||
|
|
5c3dd70bba | ||
|
|
ef14c22300 | ||
|
|
b841d3f3e4 | ||
|
|
eca77ece81 | ||
|
|
3462de1eee | ||
|
|
f66118f5bb | ||
|
|
3ef540ae37 | ||
|
|
e0edcc89ac | ||
|
|
7922eabcf0 | ||
|
|
82bfa0ea1f | ||
|
|
dc52092654 | ||
|
|
b565663645 | ||
|
|
34a5247457 | ||
|
|
9a5f82565d | ||
|
|
409cd11c21 | ||
|
|
4f1b85cf76 | ||
|
|
b7cc4d2336 | ||
|
|
44f91a2ea4 | ||
|
|
cc6db18199 | ||
|
|
6af2ed9ab5 | ||
|
|
cf64274fe6 | ||
|
|
5c7a4b64c9 | ||
|
|
31cf45ce35 | ||
|
|
a644770488 | ||
|
|
5a1d902795 | ||
|
|
901369b072 | ||
|
|
57a4d9767b | ||
|
|
8c4f85ee70 | ||
|
|
7c341d42e5 | ||
|
|
8487acf167 | ||
|
|
8606eb48c8 | ||
|
|
d83539436e | ||
|
|
e4fdf8414a | ||
|
|
0f41993ed0 | ||
|
|
a0ff17b935 |
+46
-31
@@ -1,34 +1,49 @@
|
|||||||
# Environment variables set for all cargo invocations in this workspace.
|
# audiopus_sys calls cmake::build(opus_path), so downstream Cargo env cannot
|
||||||
# CMAKE_POLICY_VERSION_MINIMUM is required for audiopus_sys's bundled
|
# call cmake-rs Config::define() to override CMake's MSVC Debug CRT defaults.
|
||||||
# Opus CMake build to succeed on CMake 4.x (which removed compatibility
|
# Instead, point cmake-rs at a small wrapper that injects -D cache/policy
|
||||||
# with cmake_minimum_required < 3.5). audiopus_sys v0.2.2 bundles
|
# variables during configure while passing cmake --build / --version / -E /
|
||||||
# Opus 1.3.1 whose CMakeLists.txt uses a very old minimum version.
|
# --install / --open through unchanged. This keeps Opus Debug builds on
|
||||||
|
# Rust's release dynamic CRT (/MD) instead of CMake's default debug CRT
|
||||||
|
# (/MDd), which otherwise pulls in unresolved __imp__CrtDbgReportW symbols
|
||||||
|
# at test link.
|
||||||
|
#
|
||||||
|
# IMPORTANT: Cargo's `[target.<triple>]` config sections only forward a
|
||||||
|
# fixed allowlist of keys (linker, runner, rustflags, rustdocflags, ar)
|
||||||
|
# to build scripts. Arbitrary keys such as `CMAKE` placed under
|
||||||
|
# `[target.<triple>]` are silently ignored and never reach the
|
||||||
|
# audiopus_sys build script. cmake-rs (via cc-style env resolution)
|
||||||
|
# looks up CMAKE in this order:
|
||||||
|
# 1. CMAKE_<target-triple-with-dashes>
|
||||||
|
# 2. CMAKE_<target_triple_with_underscores>
|
||||||
|
# 3. TARGET_CMAKE (or HOST_CMAKE when host == target)
|
||||||
|
# 4. CMAKE
|
||||||
|
# We therefore scope the wrapper to Windows MSVC targets by setting the
|
||||||
|
# target-suffixed variant in the global [env] section. Non-Windows
|
||||||
|
# hosts (macOS, Linux, iOS, Android) never see CMAKE set and invoke
|
||||||
|
# `cmake` directly.
|
||||||
|
#
|
||||||
|
# NOTE: CMAKE_POLICY_DEFAULT_CMP0091 and CMAKE_MSVC_RUNTIME_LIBRARY cannot
|
||||||
|
# be set via the process environment because CMake does NOT auto-import
|
||||||
|
# them into its cache; they must be passed as `-D` definitions, which the
|
||||||
|
# wrapper does.
|
||||||
|
#
|
||||||
|
# iOS deployment target (DEC-003: iOS 13.0 minimum) is NOT set here. It is
|
||||||
|
# enforced in two places that own the iOS build:
|
||||||
|
# 1. tools/build-ios.sh — sets IPHONEOS_DEPLOYMENT_TARGET for the cargo
|
||||||
|
# invocation and bypasses audiopus_sys's CMake build via
|
||||||
|
# LIBOPUS_STATIC=1 / LIBOPUS_NO_PKG=1 / LIBOPUS_LIB_DIR.
|
||||||
|
# 2. apps/chanora_flutter/ios/Runner.xcodeproj — sets the Xcode
|
||||||
|
# IPHONEOS_DEPLOYMENT_TARGET build setting for the final link.
|
||||||
|
# Setting it globally here would make native macOS `cargo check` runs try
|
||||||
|
# to link iPhone objects against the macOS SDK.
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||||
|
|
||||||
# iOS builds must set IPHONEOS_DEPLOYMENT_TARGET in the invoking script
|
# Scope the cmake wrapper to Windows MSVC targets only via the
|
||||||
# or Xcode build phase. Do not set it globally here: native macOS cargo
|
# target-suffixed env var name that cc/cmake-rs already resolve.
|
||||||
# checks also compile bundled C/C++ dependencies, and a global iOS
|
# Force = true so a developer's pre-existing CMAKE_x86_64-pc-windows-msvc
|
||||||
# deployment target makes clang try to link iPhone objects against the
|
# does not silently bypass the wrapper. Relative = true so the path
|
||||||
# macOS SDK.
|
# resolves from the workspace root regardless of where cargo is invoked.
|
||||||
|
CMAKE_x86_64-pc-windows-msvc = { value = "tools/cmake-msvc-release-crt.cmd", force = true, relative = true }
|
||||||
# iOS target linker flags (DEC-003: minimum deployment target iOS 13.0).
|
CMAKE_aarch64-pc-windows-msvc = { value = "tools/cmake-msvc-release-crt.cmd", force = true, relative = true }
|
||||||
#
|
|
||||||
# These rustflags pass -miphoneos-version-min=13.0 to the linker, ensuring
|
|
||||||
# the final binary targets iOS 13.0+. This is defense-in-depth alongside
|
|
||||||
# the IPHONEOS_DEPLOYMENT_TARGET env var above — the env var affects C
|
|
||||||
# compilation (cc crate, CMake), while these rustflags affect the final
|
|
||||||
# link step.
|
|
||||||
#
|
|
||||||
# NOTE: The canonical iOS build is done via tools/build-ios.sh, which
|
|
||||||
# sets LIBOPUS_STATIC=1, LIBOPUS_NO_PKG=1, and LIBOPUS_LIB_DIR to
|
|
||||||
# bypass audiopus_sys's CMake build entirely.
|
|
||||||
|
|
||||||
[target.aarch64-apple-ios]
|
|
||||||
rustflags = ["-C", "link-arg=-miphoneos-version-min=13.0"]
|
|
||||||
|
|
||||||
[target.aarch64-apple-ios-sim]
|
|
||||||
rustflags = ["-C", "link-arg=-miphonesimulator-version-min=13.0"]
|
|
||||||
|
|
||||||
[target.x86_64-apple-ios]
|
|
||||||
rustflags = ["-C", "link-arg=-miphonesimulator-version-min=13.0"]
|
|
||||||
|
|||||||
+124
-15
@@ -25,15 +25,10 @@ jobs:
|
|||||||
run: cargo check --workspace --locked
|
run: cargo check --workspace --locked
|
||||||
- name: cargo test --workspace
|
- name: cargo test --workspace
|
||||||
env:
|
env:
|
||||||
# Storage tests must not hit the real OS keyring on CI:
|
|
||||||
# there is no D-Bus session available and the call would
|
|
||||||
# block. The runtime code carries the same toggle for
|
|
||||||
# headless / sandboxed environments.
|
|
||||||
CHANORA_DISABLE_KEYRING: "1"
|
CHANORA_DISABLE_KEYRING: "1"
|
||||||
run: cargo test --workspace --locked --no-fail-fast
|
run: cargo test --workspace --locked --no-fail-fast
|
||||||
- name: cargo clippy
|
- name: cargo clippy
|
||||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
supply-chain:
|
supply-chain:
|
||||||
name: cargo deny (licenses + advisories + bans + sources)
|
name: cargo deny (licenses + advisories + bans + sources)
|
||||||
@@ -43,9 +38,6 @@ jobs:
|
|||||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||||
with:
|
with:
|
||||||
command: check
|
command: check
|
||||||
# `licenses` enforces the DEC-020 license posture; the
|
|
||||||
# other three are minimal supply-chain hygiene per
|
|
||||||
# `docs/governance/legal-review-readiness.md` §5.
|
|
||||||
arguments: --workspace --all-features
|
arguments: --workspace --all-features
|
||||||
|
|
||||||
license-inventory:
|
license-inventory:
|
||||||
@@ -58,10 +50,6 @@ jobs:
|
|||||||
- name: Install cargo-about
|
- name: Install cargo-about
|
||||||
run: cargo install --locked --features cli cargo-about
|
run: cargo install --locked --features cli cargo-about
|
||||||
- name: Regenerate inventory and compare
|
- name: Regenerate inventory and compare
|
||||||
# Build the inventory in a temp file and diff against the
|
|
||||||
# committed copy. CI fails when the committed inventory is
|
|
||||||
# stale, forcing contributors to run the tool locally
|
|
||||||
# before opening a PR that touches the dependency tree.
|
|
||||||
run: |
|
run: |
|
||||||
cargo about generate --output-file /tmp/license-inventory.md about-md.hbs
|
cargo about generate --output-file /tmp/license-inventory.md about-md.hbs
|
||||||
diff docs/security/license-inventory.md /tmp/license-inventory.md \
|
diff docs/security/license-inventory.md /tmp/license-inventory.md \
|
||||||
@@ -80,9 +68,6 @@ jobs:
|
|||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
- name: Regenerate Flutter license inventory and compare
|
- name: Regenerate Flutter license inventory and compare
|
||||||
env:
|
env:
|
||||||
# Resolved by the wrapper from $HOME/sdks/flutter when
|
|
||||||
# not set; CI's subosito/flutter-action puts flutter on
|
|
||||||
# PATH but exports the SDK root under FLUTTER_ROOT.
|
|
||||||
FLUTTER_ROOT: ${{ env.FLUTTER_ROOT }}
|
FLUTTER_ROOT: ${{ env.FLUTTER_ROOT }}
|
||||||
run: |
|
run: |
|
||||||
./tools/dump_flutter_licenses.sh
|
./tools/dump_flutter_licenses.sh
|
||||||
@@ -136,3 +121,127 @@ jobs:
|
|||||||
if: steps.silero-coreml.outputs.available == 'true'
|
if: steps.silero-coreml.outputs.available == 'true'
|
||||||
working-directory: apps/chanora_flutter
|
working-directory: apps/chanora_flutter
|
||||||
run: flutter build ios --release --no-codesign
|
run: flutter build ios --release --no-codesign
|
||||||
|
- name: xcodebuild archive verification
|
||||||
|
if: steps.silero-coreml.outputs.available == 'true'
|
||||||
|
working-directory: apps/chanora_flutter
|
||||||
|
run: |
|
||||||
|
xcodebuild archive \
|
||||||
|
-workspace ios/Runner.xcworkspace \
|
||||||
|
-scheme Runner \
|
||||||
|
-archive build/Runner.xcarchive \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
| xcpretty || { echo "::error::xcodebuild archive failed — see issue-history-analysis.md §4 'Xcode Archive vs build divergence'"; exit 1; }
|
||||||
|
|
||||||
|
android-build:
|
||||||
|
name: Android build (${{ matrix.target }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: aarch64-linux-android
|
||||||
|
abi: arm64-v8a
|
||||||
|
- target: armv7-linux-androideabi
|
||||||
|
abi: armeabi-v7a
|
||||||
|
- target: x86_64-linux-android
|
||||||
|
abi: x86_64
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install cargo-ndk
|
||||||
|
run: cargo install --locked cargo-ndk
|
||||||
|
- name: Setup NDK
|
||||||
|
run: |
|
||||||
|
ANDROID_ROOT="/usr/local/lib/android/sdk"
|
||||||
|
SDKMANAGER="$ANDROID_ROOT/cmdline-tools/latest/bin/sdkmanager"
|
||||||
|
echo "y" | $SDKMANAGER "ndk;27.0.12077973"
|
||||||
|
echo "ANDROID_NDK_HOME=$ANDROID_ROOT/ndk/27.0.12077973" >> "$GITHUB_ENV"
|
||||||
|
- name: cargo ndk build
|
||||||
|
run: cargo ndk -t ${{ matrix.abi }} build --workspace --locked
|
||||||
|
|
||||||
|
windows-build:
|
||||||
|
name: Windows build
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: cargo check --workspace
|
||||||
|
run: cargo check --workspace --locked
|
||||||
|
|
||||||
|
macos-build:
|
||||||
|
name: macOS build
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: cargo check --workspace
|
||||||
|
run: cargo check --workspace --locked
|
||||||
|
|
||||||
|
linux-multi-distro:
|
||||||
|
name: Linux build (${{ matrix.distro }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- distro: ubuntu
|
||||||
|
image: ubuntu:24.04
|
||||||
|
install: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y curl build-essential pkg-config \
|
||||||
|
libasound2-dev libpulse-dev libdbus-1-dev libsdl2-dev libopus-dev
|
||||||
|
- distro: fedora
|
||||||
|
image: fedora:latest
|
||||||
|
install: |
|
||||||
|
dnf install -y curl gcc pkg-config \
|
||||||
|
alsa-lib-devel pulseaudio-libs-devel dbus-devel SDL2-devel opus-devel
|
||||||
|
- distro: arch
|
||||||
|
image: archlinux:latest
|
||||||
|
install: |
|
||||||
|
pacman -Syu --noconfirm
|
||||||
|
pacman -S --noconfirm curl base-devel pkg-config \
|
||||||
|
alsa-lib pulseaudio dbus sdl2 opus
|
||||||
|
container:
|
||||||
|
image: ${{ matrix.image }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: ${{ matrix.install }}
|
||||||
|
- name: Install Rust
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: cargo check --workspace
|
||||||
|
run: cargo check --workspace
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
name: cargo llvm-cov
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: System deps
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libasound2-dev libpulse-dev pkg-config \
|
||||||
|
libdbus-1-dev libsdl2-dev libopus-dev
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: llvm-tools-preview
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install cargo-llvm-cov
|
||||||
|
run: cargo install --locked cargo-llvm-cov
|
||||||
|
- name: Generate coverage
|
||||||
|
env:
|
||||||
|
CHANORA_DISABLE_KEYRING: "1"
|
||||||
|
run: cargo llvm-cov --workspace --lcov --output-path lcov.info
|
||||||
|
- name: Upload coverage artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: lcov-report
|
||||||
|
path: lcov.info
|
||||||
|
|||||||
+16
-12
@@ -8,8 +8,10 @@ This project follows a Conventional Commits style workflow.
|
|||||||
|
|
||||||
The v0.3.0 milestone transitions Chanora from an internal-beta voice
|
The v0.3.0 milestone transitions Chanora from an internal-beta voice
|
||||||
prototype to a cross-platform baseline client with event-driven UI,
|
prototype to a cross-platform baseline client with event-driven UI,
|
||||||
per-user audio controls, non-self client info parity, and CI-hardened
|
visible per-client audio state, non-self client info parity, and
|
||||||
Android / iOS / macOS / Linux builds.
|
documented host Rust workspace plus Flutter validation gates. Android
|
||||||
|
target compile/install/smoke evidence remains blocked locally pending the
|
||||||
|
required NDK compiler and an authorized ADB target.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -17,10 +19,9 @@ Android / iOS / macOS / Linux builds.
|
|||||||
deltas (client join/leave/move/update, channel add/remove/update)
|
deltas (client join/leave/move/update, channel add/remove/update)
|
||||||
flow through a typed `ProtocolDelta` enum and update the Flutter UI
|
flow through a typed `ProtocolDelta` enum and update the Flutter UI
|
||||||
in real time. Channel switching is instant.
|
in real time. Channel switching is instant.
|
||||||
- **Per-user volume controls.** Each client in the snapshot gets an
|
- **Per-client audio state visibility.** Client rows surface
|
||||||
independent volume slider persisted in the bridge layer. Avatar
|
muted/deafened state in avatar badges. Per-user volume UI, persistence,
|
||||||
badges show muted/deafened state. Volume adjustments take effect
|
and mixer wiring remain tracked as follow-up work.
|
||||||
immediately on the audio mix.
|
|
||||||
- **Non-self client info parity with Qint.** The Info tab now populates
|
- **Non-self client info parity with Qint.** The Info tab now populates
|
||||||
connection metadata (name, description, created, last connected,
|
connection metadata (name, description, created, last connected,
|
||||||
connections, transfer, ping deviation) for other clients via an
|
connections, transfer, ping deviation) for other clients via an
|
||||||
@@ -29,9 +30,10 @@ Android / iOS / macOS / Linux builds.
|
|||||||
- **Ping deviation in client profiles.** `ping_deviation_milliseconds`
|
- **Ping deviation in client profiles.** `ping_deviation_milliseconds`
|
||||||
propagated from protocol DTO through bridge API to Dart, with a
|
propagated from protocol DTO through bridge API to Dart, with a
|
||||||
conditional l10n row in the client info sheet (en + zh).
|
conditional l10n row in the client info sheet (en + zh).
|
||||||
- **Apple CoreML Silero VAD** as the preferred voice activity detector
|
- **Apple CoreML Silero VAD scaffolding/assets** for iOS / macOS when
|
||||||
on iOS / macOS when the private `silero-coreml` SwiftPM submodule is
|
the private `silero-coreml` SwiftPM package is available. Product
|
||||||
available. WebRTC VAD remains the runtime fallback.
|
`VoiceActivity` remains reserved/disabled per DEC-030 until a later
|
||||||
|
baseline enables and verifies it.
|
||||||
- **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
|
- **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
|
||||||
lookups and `ts3server://` URI handling.
|
lookups and `ts3server://` URI handling.
|
||||||
- **Per-ABI Android APK splitting.** `flutter build apk
|
- **Per-ABI Android APK splitting.** `flutter build apk
|
||||||
@@ -50,8 +52,9 @@ Android / iOS / macOS / Linux builds.
|
|||||||
- **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
|
- **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
|
||||||
serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
|
serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
|
||||||
audio startup.
|
audio startup.
|
||||||
- **Linux native audio path promoted** with ONNX Runtime bundled for
|
- **Linux native audio path promoted** with ONNX Runtime VAD assets
|
||||||
VAD. Desktop voice I/O works on PipeWire / PulseAudio.
|
bundled for future `VoiceActivity` work. Desktop voice I/O works on
|
||||||
|
PipeWire / PulseAudio; product `VoiceActivity` remains disabled.
|
||||||
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
|
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
|
||||||
startup permission flow, and system back-button integration.
|
startup permission flow, and system back-button integration.
|
||||||
- **`SnapshotChanged` event removed.** Replaced by the typed delta
|
- **`SnapshotChanged` event removed.** Replaced by the typed delta
|
||||||
@@ -59,7 +62,8 @@ Android / iOS / macOS / Linux builds.
|
|||||||
Flutter).
|
Flutter).
|
||||||
- **Prefetch crate renamed** from the PoC-era name to
|
- **Prefetch crate renamed** from the PoC-era name to
|
||||||
`chanora_prefetch`. All docs, specs, and code updated.
|
`chanora_prefetch`. All docs, specs, and code updated.
|
||||||
- **Build number bumped to 76.**
|
- **Flutter app version/build bumped to `0.3.0+100`.** Rust workspace
|
||||||
|
packages remain versioned separately at `0.2.0-beta.1`.
|
||||||
- **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
|
- **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Generated
+370
-16
@@ -148,12 +148,111 @@ version = "0.7.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-listener",
|
"event-listener 5.4.1",
|
||||||
"event-listener-strategy",
|
"event-listener-strategy",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-channel"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
|
||||||
|
dependencies = [
|
||||||
|
"concurrent-queue",
|
||||||
|
"event-listener 2.5.3",
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-channel"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||||
|
dependencies = [
|
||||||
|
"concurrent-queue",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-executor"
|
||||||
|
version = "1.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||||
|
dependencies = [
|
||||||
|
"async-task",
|
||||||
|
"concurrent-queue",
|
||||||
|
"fastrand",
|
||||||
|
"futures-lite",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-global-executor"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel 2.5.0",
|
||||||
|
"async-executor",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"blocking",
|
||||||
|
"futures-lite",
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-io"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"cfg-if",
|
||||||
|
"concurrent-queue",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"parking",
|
||||||
|
"polling",
|
||||||
|
"rustix",
|
||||||
|
"slab",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-lock"
|
||||||
|
version = "3.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener 5.4.1",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-process"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel 2.5.0",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-signal",
|
||||||
|
"async-task",
|
||||||
|
"blocking",
|
||||||
|
"cfg-if",
|
||||||
|
"event-listener 5.4.1",
|
||||||
|
"futures-lite",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-recursion"
|
name = "async-recursion"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -165,6 +264,57 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-signal"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||||
|
dependencies = [
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"atomic-waker",
|
||||||
|
"cfg-if",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"rustix",
|
||||||
|
"signal-hook-registry",
|
||||||
|
"slab",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-std"
|
||||||
|
version = "1.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel 1.9.0",
|
||||||
|
"async-global-executor",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-process",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"gloo-timers",
|
||||||
|
"kv-log-macro",
|
||||||
|
"log",
|
||||||
|
"memchr",
|
||||||
|
"once_cell",
|
||||||
|
"pin-project-lite",
|
||||||
|
"pin-utils",
|
||||||
|
"slab",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-task"
|
||||||
|
version = "4.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -257,6 +407,12 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.21.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -308,6 +464,19 @@ dependencies = [
|
|||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blocking"
|
||||||
|
version = "1.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel 2.5.0",
|
||||||
|
"async-task",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"piper",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "build-target"
|
name = "build-target"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -352,6 +521,32 @@ version = "1.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cacache"
|
||||||
|
version = "13.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c5063741c7b2e260bbede781cf4679632dd90e2718e99f7715e46824b65670b"
|
||||||
|
dependencies = [
|
||||||
|
"async-std",
|
||||||
|
"digest 0.10.7",
|
||||||
|
"either",
|
||||||
|
"futures",
|
||||||
|
"hex",
|
||||||
|
"libc",
|
||||||
|
"memmap2",
|
||||||
|
"miette",
|
||||||
|
"reflink-copy",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"sha1",
|
||||||
|
"sha2",
|
||||||
|
"ssri",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cast"
|
name = "cast"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -468,6 +663,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -475,11 +671,23 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chanora_cache"
|
||||||
|
version = "0.2.0-beta.1"
|
||||||
|
dependencies = [
|
||||||
|
"cacache",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chanora_core"
|
name = "chanora_core"
|
||||||
version = "0.2.0-beta.1"
|
version = "0.2.0-beta.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chanora_audio",
|
"chanora_audio",
|
||||||
|
"chanora_cache",
|
||||||
"chanora_diagnostics",
|
"chanora_diagnostics",
|
||||||
"chanora_prefetch",
|
"chanora_prefetch",
|
||||||
"chanora_protocol",
|
"chanora_protocol",
|
||||||
@@ -515,11 +723,12 @@ name = "chanora_protocol"
|
|||||||
version = "0.2.0-beta.1"
|
version = "0.2.0-beta.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"chanora_resolver",
|
"chanora_resolver",
|
||||||
"futures",
|
"futures",
|
||||||
"reqwest 0.13.4",
|
"reqwest 0.13.4",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -532,7 +741,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chanora_resolver"
|
name = "chanora_resolver"
|
||||||
version = "0.1.0"
|
version = "0.2.0-beta.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"hickory-resolver",
|
"hickory-resolver",
|
||||||
@@ -554,7 +763,7 @@ dependencies = [
|
|||||||
name = "chanora_storage"
|
name = "chanora_storage"
|
||||||
version = "0.2.0-beta.1"
|
version = "0.2.0-beta.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"keyring",
|
"keyring",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
@@ -1239,6 +1448,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener"
|
||||||
|
version = "2.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "event-listener"
|
name = "event-listener"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -1256,7 +1471,7 @@ version = "0.5.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-listener",
|
"event-listener 5.4.1",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1559,6 +1774,18 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gloo-timers"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "group"
|
name = "group"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -1837,7 +2064,7 @@ version = "0.1.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -2142,6 +2369,15 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kv-log-macro"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -2226,6 +2462,9 @@ name = "log"
|
|||||||
version = "0.4.31"
|
version = "0.4.31"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
|
checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
|
||||||
|
dependencies = [
|
||||||
|
"value-bag",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
@@ -2274,6 +2513,15 @@ version = "2.8.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memmap2"
|
||||||
|
version = "0.5.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memoffset"
|
name = "memoffset"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -2283,6 +2531,29 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miette"
|
||||||
|
version = "5.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e"
|
||||||
|
dependencies = [
|
||||||
|
"miette-derive",
|
||||||
|
"once_cell",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miette-derive"
|
||||||
|
version = "5.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
@@ -2813,6 +3084,17 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "piper"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"fastrand",
|
||||||
|
"futures-io",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkcs8"
|
name = "pkcs8"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -2857,6 +3139,20 @@ dependencies = [
|
|||||||
"plotters-backend",
|
"plotters-backend",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polling"
|
||||||
|
version = "3.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"concurrent-queue",
|
||||||
|
"hermit-abi",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "poly1305"
|
name = "poly1305"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -3183,6 +3479,18 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reflink-copy"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13362233b147e57674c37b802d216b7c5e3dcccbed8967c84f0d8d223868ae27"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.3"
|
version = "1.12.3"
|
||||||
@@ -3218,7 +3526,7 @@ version = "0.12.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"http",
|
"http",
|
||||||
@@ -3256,7 +3564,7 @@ version = "0.13.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -3672,6 +3980,17 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -3851,6 +4170,23 @@ dependencies = [
|
|||||||
"der",
|
"der",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ssri"
|
||||||
|
version = "9.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.21.7",
|
||||||
|
"digest 0.10.7",
|
||||||
|
"hex",
|
||||||
|
"miette",
|
||||||
|
"serde",
|
||||||
|
"sha-1",
|
||||||
|
"sha2",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"xxhash-rust",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -4349,7 +4685,7 @@ name = "ts-bookkeeping"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"heck",
|
"heck",
|
||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"num-derive",
|
"num-derive",
|
||||||
@@ -4370,7 +4706,7 @@ version = "0.2.0"
|
|||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"audiopus",
|
"audiopus",
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"futures",
|
"futures",
|
||||||
"git-testament",
|
"git-testament",
|
||||||
"hickory-net",
|
"hickory-net",
|
||||||
@@ -4398,7 +4734,7 @@ version = "0.2.0"
|
|||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"curve25519-dalek-ng",
|
"curve25519-dalek-ng",
|
||||||
"eax",
|
"eax",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4427,7 +4763,7 @@ name = "tsproto-packets"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bitflags 2.12.1",
|
"bitflags 2.12.1",
|
||||||
"num-derive",
|
"num-derive",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
@@ -4442,7 +4778,7 @@ name = "tsproto-structs"
|
|||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "git+https://github.com/EdisonJwa/tsclientlib.git?branch=fix%2Fp256-short-coordinate-pad#8b7a3226c692319b714ea1d32fd5ded05911aa40"
|
source = "git+https://github.com/EdisonJwa/tsclientlib.git?branch=fix%2Fp256-short-coordinate-pad#8b7a3226c692319b714ea1d32fd5ded05911aa40"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"csv",
|
"csv",
|
||||||
"heck",
|
"heck",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -4455,7 +4791,7 @@ name = "tsproto-structs"
|
|||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"csv",
|
"csv",
|
||||||
"heck",
|
"heck",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -4468,7 +4804,7 @@ name = "tsproto-types"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/EdisonJwa/tsclientlib.git?branch=fix%2Fp256-short-coordinate-pad#8b7a3226c692319b714ea1d32fd5ded05911aa40"
|
source = "git+https://github.com/EdisonJwa/tsclientlib.git?branch=fix%2Fp256-short-coordinate-pad#8b7a3226c692319b714ea1d32fd5ded05911aa40"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bitflags 2.12.1",
|
"bitflags 2.12.1",
|
||||||
"curve25519-dalek-ng",
|
"curve25519-dalek-ng",
|
||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
@@ -4512,6 +4848,12 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -4570,6 +4912,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "value-bag"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -5256,6 +5604,12 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xxhash-rust"
|
||||||
|
version = "0.8.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -5289,7 +5643,7 @@ dependencies = [
|
|||||||
"async-recursion",
|
"async-recursion",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"event-listener",
|
"event-listener 5.4.1",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"hex",
|
"hex",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
# crates/chanora_resolver/ — TeamSpeak address resolution
|
# crates/chanora_resolver/ — TeamSpeak address resolution
|
||||||
# crates/chanora_state/ — snapshot, deltas, reducers
|
# crates/chanora_state/ — snapshot, deltas, reducers
|
||||||
# crates/chanora_audio/ — capture, DSP, Opus, jitter, mixer
|
# crates/chanora_audio/ — capture, DSP, Opus, jitter, mixer
|
||||||
|
# crates/chanora_cache/ — avatar/icon blob cache (cacache-backed)
|
||||||
# crates/chanora_storage/ — bookmarks, settings, identity refs
|
# crates/chanora_storage/ — bookmarks, settings, identity refs
|
||||||
# crates/chanora_diagnostics/ — logs, redaction, export
|
# crates/chanora_diagnostics/ — logs, redaction, export
|
||||||
# crates/chanora_prefetch — server-resolution prefetch cache/policy
|
# crates/chanora_prefetch — server-resolution prefetch cache/policy
|
||||||
@@ -30,6 +31,7 @@ members = [
|
|||||||
"crates/chanora_state",
|
"crates/chanora_state",
|
||||||
"crates/chanora_audio",
|
"crates/chanora_audio",
|
||||||
"crates/chanora_storage",
|
"crates/chanora_storage",
|
||||||
|
"crates/chanora_cache",
|
||||||
"crates/chanora_diagnostics",
|
"crates/chanora_diagnostics",
|
||||||
"crates/chanora_prefetch",
|
"crates/chanora_prefetch",
|
||||||
"crates/chanora_bridge",
|
"crates/chanora_bridge",
|
||||||
@@ -38,6 +40,8 @@ members = [
|
|||||||
|
|
||||||
exclude = [
|
exclude = [
|
||||||
"apps/chanora_flutter",
|
"apps/chanora_flutter",
|
||||||
|
"tools/protocol-probe",
|
||||||
|
"tools/audio-test",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by the Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
Copyright 2024-2026 Chanora Contributors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024-2026 Chanora Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -14,10 +14,10 @@ Flutter UI + Rust Core + tsclientlib
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Chanora is currently in early planning and baseline-candidate design.
|
Chanora is currently a baseline-candidate Flutter + Rust workspace. It is not production-ready and is not approved for public or store release.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Current documentation baseline: v0.9.2
|
Current documentation baseline: v0.9.x document set
|
||||||
Current status: Baseline Candidate
|
Current status: Baseline Candidate
|
||||||
Implementation status: Not production-ready
|
Implementation status: Not production-ready
|
||||||
```
|
```
|
||||||
@@ -25,7 +25,7 @@ Implementation status: Not production-ready
|
|||||||
The current engineering focus is:
|
The current engineering focus is:
|
||||||
|
|
||||||
- defining the system and software architecture;
|
- defining the system and software architecture;
|
||||||
- preparing the Flutter + Rust application structure;
|
- hardening the Flutter + Rust application structure;
|
||||||
- validating TeamSpeak-compatible protocol integration through `tsclientlib`;
|
- validating TeamSpeak-compatible protocol integration through `tsclientlib`;
|
||||||
- defining cross-platform audio behavior;
|
- defining cross-platform audio behavior;
|
||||||
- preparing release, verification, security, privacy, and legal gates.
|
- preparing release, verification, security, privacy, and legal gates.
|
||||||
@@ -49,7 +49,7 @@ Current platform policy:
|
|||||||
| iOS / iPadOS runtime target | iOS 16+ while Apple CoreML Silero VAD is linked |
|
| iOS / iPadOS runtime target | iOS 16+ while Apple CoreML Silero VAD is linked |
|
||||||
| macOS runtime target | macOS 13+ while Apple CoreML Silero VAD is linked |
|
| macOS runtime target | macOS 13+ while Apple CoreML Silero VAD is linked |
|
||||||
| App Store Connect upload gate | Xcode 26+ with iOS 26 / iPadOS 26 SDK+ for upload on or after 2026-04-28 |
|
| App Store Connect upload gate | Xcode 26+ with iOS 26 / iPadOS 26 SDK+ for upload on or after 2026-04-28 |
|
||||||
| Android runtime target | Android API 24+ unless Flutter, plugin, audio, or product constraints require raising it |
|
| Android runtime target | Android API 28+ per DEC-004, SysRS-288, SRS-187, and Gradle `minSdk = 28` |
|
||||||
| Google Play target API | Target the Google Play-required API level on upload date |
|
| Google Play target API | Target the Google Play-required API level on upload date |
|
||||||
|
|
||||||
The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
|
The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
|
||||||
@@ -230,7 +230,7 @@ docs/
|
|||||||
aspice-swe2-swe3-integration-note.md
|
aspice-swe2-swe3-integration-note.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Implementation source folders may be added later. A likely structure is:
|
Implementation source folders are present in this workspace. The current high-level structure is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
apps/
|
apps/
|
||||||
@@ -248,7 +248,7 @@ crates/
|
|||||||
chanora_bridge/
|
chanora_bridge/
|
||||||
```
|
```
|
||||||
|
|
||||||
The exact implementation layout should be finalized when the repository scaffold is created.
|
The exact implementation layout may continue to evolve as maintainability reviews split or merge Modules, but the repository scaffold exists.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -395,9 +395,7 @@ docs/governance/git-commit-message-convention.md
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Implementation commands will be added after the repository scaffold is finalized.
|
Common local commands include:
|
||||||
|
|
||||||
Expected future commands may include:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter pub get
|
flutter pub get
|
||||||
@@ -407,7 +405,7 @@ cargo clippy
|
|||||||
cargo fmt
|
cargo fmt
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not treat these as authoritative until the actual Flutter/Rust workspace has been created.
|
Android runtime success also requires an available Android NDK toolchain and an authorized device or emulator for build/install/smoke verification.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ android {
|
|||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -198,6 +199,7 @@ android {
|
|||||||
// armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB.
|
// armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB.
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0")
|
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0")
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,3C8.69,3 6,5.69 6,9V13C6,16.31 8.69,19 12,19C15.31,19 18,16.31 18,13V9C18,5.69 15.31,3 12,3ZM12,5C14.21,5 16,6.79 16,9V13C16,15.21 14.21,17 12,17C9.79,17 8,15.21 8,13V9C8,6.79 9.79,5 12,5ZM11,20V22H13V20H11Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
tools:keep="@drawable/ic_chanora_notification" />
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
|
||||||
// Mirror Release.xcconfig (see explanation there).
|
// Mirror Release.xcconfig (see explanation there). `-u` is the load-bearing
|
||||||
OTHER_LDFLAGS = $(inherited) -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
|
// flag: without it the linker drops Swift @_cdecl symbols (no Swift caller)
|
||||||
|
// before `-exported_symbol` can re-export them, and the verify_silero_exports
|
||||||
|
// build phase fails the build.
|
||||||
|
OTHER_LDFLAGS = $(inherited) -Xlinker -u -Xlinker _chanora_silero_vad_create -Xlinker -u -Xlinker _chanora_silero_vad_destroy -Xlinker -u -Xlinker _chanora_silero_vad_reset -Xlinker -u -Xlinker _chanora_silero_vad_process -Xlinker -u -Xlinker _chanora_silero_vad_last_error -Xlinker -u -Xlinker _chanora_silero_vad_free_string -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
|
||||||
STRIP_STYLE = non-global
|
STRIP_STYLE = non-global
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/haptic_kit/ios"
|
:path: ".symlinks/plugins/haptic_kit/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
chanora_bridge: 26252acdf9ca660ce9c132ad25cd5ad5af467b16
|
chanora_bridge: 27a03592058709f6f38701343eb51c3a55b02da0
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
||||||
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
||||||
|
|||||||
@@ -6,6 +6,24 @@ import AVFoundation
|
|||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
||||||
private var iosPlatformChannel: FlutterMethodChannel?
|
private var iosPlatformChannel: FlutterMethodChannel?
|
||||||
|
private var iosAudioSessionChannel: FlutterMethodChannel?
|
||||||
|
|
||||||
|
/// Tracks whether a voice channel is currently active.
|
||||||
|
///
|
||||||
|
/// The AVAudioSession is intentionally not configured for VoIP at
|
||||||
|
/// app launch — that would interrupt other apps' audio (Spotify,
|
||||||
|
/// Apple Music, podcasts) the moment the user opens Chanora, even
|
||||||
|
/// when they're just reading chat. Production VoIP apps (Telegram
|
||||||
|
/// group calls, Signal, Discord, Element) only switch the session
|
||||||
|
/// to `.playAndRecord` + `.voiceChat` when the user actually joins
|
||||||
|
/// a voice channel. See `docs/architecture/sad.md` and the
|
||||||
|
/// `chanora/ios_audio_session` MethodChannel contract.
|
||||||
|
///
|
||||||
|
/// This flag gates lifecycle handlers (interruption-ended,
|
||||||
|
/// media-services-reset) so we only rebuild the VoIP session if a
|
||||||
|
/// call is actually in progress. When false, those handlers leave
|
||||||
|
/// the session in the inactive `.ambient` baseline.
|
||||||
|
private var voiceSessionActive: Bool = false
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
@@ -15,91 +33,28 @@ import AVFoundation
|
|||||||
ChanoraSileroSelfTest.run()
|
ChanoraSileroSelfTest.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure the iOS AVAudioSession **category + mode** at
|
// AVAudioSession lifecycle policy (DEC-2026-06-08, supersedes
|
||||||
// app-launch time, but DEFER setActive(true) until the scene
|
// the launch-time .playAndRecord setup):
|
||||||
// is foregrounded. Calling setActive in didFinishLaunching is
|
|
||||||
// racy on iOS 17+ devices: if the user launches the app from a
|
|
||||||
// cold state, the UIApplication isn't yet `.active` and
|
|
||||||
// setActive returns `AVAudioSessionErrorCodeCannotStartPlaying`
|
|
||||||
// (561017449) — the iOS audio policy server refuses to grant
|
|
||||||
// the audio session because the app is not yet considered the
|
|
||||||
// foreground priority owner. Symptom in production builds:
|
|
||||||
// 'AVAudioSession setup failed: Error 561017449 "Session
|
|
||||||
// activation failed"' in NSLog, after which the audio engine
|
|
||||||
// is unusable until the user backgrounds + foregrounds the
|
|
||||||
// app.
|
|
||||||
//
|
//
|
||||||
// The category itself can be set whenever; only the active
|
// At launch we set the category to .ambient and leave the
|
||||||
// state needs to be deferred. We listen for
|
// session INACTIVE — matching the Telegram / Signal / Discord /
|
||||||
// didBecomeActiveNotification and activate then. Most
|
// Element / Jitsi pattern and Apple's guidance that "a VoIP
|
||||||
// production iOS voice apps (Discord, Zoom, FaceTime) follow
|
// app's audio session should not be active" while idle.
|
||||||
// this same shape.
|
// Configuring .playAndRecord + .voiceChat at launch stops other
|
||||||
|
// apps' music (Spotify, Apple Music, podcasts) the moment the
|
||||||
|
// user opens Chanora, even when they are just reading text chat.
|
||||||
|
//
|
||||||
|
// VoIP configuration is engaged on voice-channel join via the
|
||||||
|
// `chanora/ios_audio_session` MethodChannel, driven from Dart
|
||||||
|
// before `voiceJoin` starts VoiceProcessingIO and again as an
|
||||||
|
// idempotent guard on the AudioStarted lifecycle.
|
||||||
do {
|
do {
|
||||||
let session = AVAudioSession.sharedInstance()
|
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||||
try session.setCategory(
|
logAudioSessionState(context: "launch-ambient")
|
||||||
.playAndRecord,
|
|
||||||
mode: .voiceChat,
|
|
||||||
// Mode rationale (May 2026, .voiceChat reinstated):
|
|
||||||
//
|
|
||||||
// We previously used .default mode after discovering that
|
|
||||||
// .voiceChat routed output through iOS's in-call audio
|
|
||||||
// channel, which made speaker output barely audible. That
|
|
||||||
// bug was caused by cpal's RemoteIO unit binding to a stale
|
|
||||||
// physical transducer — after migrating to coreaudio-rs +
|
|
||||||
// kAudioUnitSubType_VoiceProcessingIO (see
|
|
||||||
// crates/chanora_audio/src/ios_voice_unit.rs) the route
|
|
||||||
// binding is correct under either mode because VPIO re-binds
|
|
||||||
// on overrideOutputAudioPort.
|
|
||||||
//
|
|
||||||
// .voiceChat advantages over .default:
|
|
||||||
// * Tells iOS this is a VoIP session — other apps' audio
|
|
||||||
// is properly ducked/paused instead of competing.
|
|
||||||
// * Enables correct Bluetooth HFP negotiation without
|
|
||||||
// manual workarounds.
|
|
||||||
// * iOS treats the audio session as a "call" for priority
|
|
||||||
// purposes (won't be interrupted by notification sounds).
|
|
||||||
// * System-level CallKit integration (lock-screen controls).
|
|
||||||
//
|
|
||||||
// .defaultToSpeaker ensures output goes to the main speaker
|
|
||||||
// (not the earpiece) by default when no headphones are
|
|
||||||
// connected, compensating for the in-call channel's tendency
|
|
||||||
// to route to the earpiece.
|
|
||||||
//
|
|
||||||
// References:
|
|
||||||
// * https://github.com/twilio/video-quickstart-ios/issues/522
|
|
||||||
// * https://stackoverflow.com/questions/79834998 (Daily.co)
|
|
||||||
//
|
|
||||||
// Options:
|
|
||||||
// .defaultToSpeaker : route output to the main speaker
|
|
||||||
// (not the earpiece) by default
|
|
||||||
// when no headphones are connected.
|
|
||||||
// .allowBluetoothHFP : permit Bluetooth Hands-Free
|
|
||||||
// Profile headsets as both input
|
|
||||||
// and output.
|
|
||||||
// .allowBluetoothA2DP : permit higher-quality A2DP
|
|
||||||
// output-only Bluetooth devices.
|
|
||||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
|
||||||
)
|
|
||||||
// Match VPIO / Opus frame cadence to reduce callback pressure.
|
|
||||||
try session.setPreferredIOBufferDuration(0.02)
|
|
||||||
try session.setPreferredSampleRate(48000.0)
|
|
||||||
logAudioSessionState(context: "setCategory")
|
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
|
NSLog("chanora_flutter: AVAudioSession .ambient baseline failed: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate the session once the app is actually foreground. The
|
|
||||||
// notification fires immediately after the cold-launch settles,
|
|
||||||
// and again on every resume-from-background — both safe
|
|
||||||
// moments to call setActive(true). Repeated activation while
|
|
||||||
// already-active is a no-op per the docs.
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(activateAudioSession),
|
|
||||||
name: UIApplication.didBecomeActiveNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(handleRouteChange(_:)),
|
selector: #selector(handleRouteChange(_:)),
|
||||||
@@ -124,37 +79,60 @@ import AVFoundation
|
|||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called by `didBecomeActiveNotification` (cold-launch settle +
|
/// Activate the VoIP audio session. Called from Dart via the
|
||||||
/// every resume-from-background). Activates the AVAudioSession.
|
/// `chanora/ios_audio_session` channel before a voice channel join
|
||||||
/// Repeated activation is a no-op when the session is already
|
/// starts VoiceProcessingIO. Configures
|
||||||
/// active so this is safe to call on every foreground.
|
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
|
||||||
@objc private func activateAudioSession() {
|
/// (Spotify, podcasts) can keep playing alongside the voice
|
||||||
|
/// channel — matching the Telegram group-call UX. Idempotent:
|
||||||
|
/// repeated calls while already active are a no-op.
|
||||||
|
private func activateVoiceSession() {
|
||||||
do {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
let session = AVAudioSession.sharedInstance()
|
||||||
NSLog("chanora_flutter: AVAudioSession activated on foreground")
|
try session.setCategory(
|
||||||
// Read back the ACTUAL session state. preferredSampleRate /
|
.playAndRecord,
|
||||||
// preferredIOBufferDuration are hints; iOS may pick something
|
mode: .voiceChat,
|
||||||
// else depending on hardware + currently-engaged effects.
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
||||||
// Without these we can't tell whether VPIO is running at
|
)
|
||||||
// 48 kHz mono (what our render callback assumes) or at e.g.
|
try session.setPreferredIOBufferDuration(0.02)
|
||||||
// 44.1 kHz (which would explain the user's broken playback
|
try session.setPreferredSampleRate(48000.0)
|
||||||
// \u2014 our render callback would be writing samples at the
|
try session.setActive(true, options: [])
|
||||||
// wrong rate, causing pitch + timing artifacts).
|
voiceSessionActive = true
|
||||||
logAudioSessionState(context: "setActive")
|
logAudioSessionState(context: "activateVoiceSession")
|
||||||
let s = AVAudioSession.sharedInstance()
|
let ins = session.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||||
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
|
||||||
NSLog(
|
NSLog(
|
||||||
"chanora_flutter: AVAudioSession actual: " +
|
"chanora_flutter: voice session active: " +
|
||||||
"sampleRate=\(s.sampleRate) " +
|
"sampleRate=\(session.sampleRate) " +
|
||||||
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
|
"ioBufferDuration=\(String(format: "%.4f", session.ioBufferDuration)) " +
|
||||||
"inputs=[\(ins)] " +
|
"inputs=[\(ins)] outputVolume=\(session.outputVolume)"
|
||||||
"outputVolume=\(s.outputVolume)"
|
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
|
NSLog("chanora_flutter: activateVoiceSession failed: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deactivate the VoIP audio session and return to the idle
|
||||||
|
/// .ambient baseline. Called from Dart on `BridgeEvent::AudioStopped`
|
||||||
|
/// (intentional leave, disconnect, or connection lost).
|
||||||
|
/// `.notifyOthersOnDeactivation` lets other audio apps know they
|
||||||
|
/// can resume — best-effort: Apple Music / Podcasts resume
|
||||||
|
/// reliably, Spotify is not guaranteed.
|
||||||
|
private func deactivateVoiceSession() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
try session.setActive(false, options: [.notifyOthersOnDeactivation])
|
||||||
|
} catch {
|
||||||
|
NSLog("chanora_flutter: deactivateVoiceSession setActive(false) failed: \(error)")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try session.setCategory(.ambient, mode: .default)
|
||||||
|
} catch {
|
||||||
|
NSLog("chanora_flutter: deactivateVoiceSession setCategory(.ambient) failed: \(error)")
|
||||||
|
}
|
||||||
|
voiceSessionActive = false
|
||||||
|
logAudioSessionState(context: "deactivateVoiceSession")
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads back the actual AVAudioSession state and logs it for
|
/// Reads back the actual AVAudioSession state and logs it for
|
||||||
/// SDD-098 compliance. Called after both setCategory and setActive
|
/// SDD-098 compliance. Called after both setCategory and setActive
|
||||||
/// to verify that the session accepted the requested configuration.
|
/// to verify that the session accepted the requested configuration.
|
||||||
@@ -219,25 +197,30 @@ import AVFoundation
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
||||||
NSLog("chanora_flutter: media services reset")
|
NSLog("chanora_flutter: media services reset voiceActive=\(voiceSessionActive)")
|
||||||
do {
|
if voiceSessionActive {
|
||||||
let session = AVAudioSession.sharedInstance()
|
do {
|
||||||
try session.setCategory(
|
let session = AVAudioSession.sharedInstance()
|
||||||
.playAndRecord,
|
try session.setCategory(
|
||||||
mode: .voiceChat,
|
.playAndRecord,
|
||||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
mode: .voiceChat,
|
||||||
)
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
||||||
try session.setPreferredIOBufferDuration(0.02)
|
)
|
||||||
try session.setPreferredSampleRate(48000.0)
|
try session.setPreferredIOBufferDuration(0.02)
|
||||||
try session.setActive(true, options: [])
|
try session.setPreferredSampleRate(48000.0)
|
||||||
logAudioSessionState(context: "mediaServicesWereReset")
|
try session.setActive(true, options: [])
|
||||||
} catch {
|
logAudioSessionState(context: "mediaServicesWereReset-voip")
|
||||||
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
} catch {
|
||||||
|
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||||
|
logAudioSessionState(context: "mediaServicesWereReset-ambient")
|
||||||
|
} catch {
|
||||||
|
NSLog("chanora_flutter: AVAudioSession media-services reset ambient restore failed: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// P1: After rebuilding the session, send the current route class to
|
|
||||||
// Rust so it can recompute the processing policy and reset the
|
|
||||||
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
|
|
||||||
// which calls ios_restart_voice_unit.
|
|
||||||
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
|
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
|
||||||
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
||||||
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
||||||
@@ -269,6 +252,26 @@ import AVFoundation
|
|||||||
name: "chanora/ios_platform",
|
name: "chanora/ios_platform",
|
||||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||||
)
|
)
|
||||||
|
iosAudioSessionChannel = FlutterMethodChannel(
|
||||||
|
name: "chanora/ios_audio_session",
|
||||||
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||||
|
)
|
||||||
|
iosAudioSessionChannel?.setMethodCallHandler { [weak self] call, result in
|
||||||
|
guard let self = self else {
|
||||||
|
result(FlutterError(code: "delegate_gone", message: "AppDelegate deallocated", details: nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch call.method {
|
||||||
|
case "activateVoiceSession":
|
||||||
|
self.activateVoiceSession()
|
||||||
|
result(nil)
|
||||||
|
case "deactivateVoiceSession":
|
||||||
|
self.deactivateVoiceSession()
|
||||||
|
result(nil)
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
iosPlatformChannel?.setMethodCallHandler { call, result in
|
iosPlatformChannel?.setMethodCallHandler { call, result in
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "getMicrophonePermissionState":
|
case "getMicrophonePermissionState":
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<true/>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
@@ -34,6 +34,8 @@
|
|||||||
<string>Chanora needs local network access to connect to your voice servers.</string>
|
<string>Chanora needs local network access to connect to your voice servers.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Chanora needs microphone access so you can talk on your voice server.</string>
|
<string>Chanora needs microphone access so you can talk on your voice server.</string>
|
||||||
|
<key>NSUserNotificationsUsageDescription</key>
|
||||||
|
<string>Chanora sends you a notification when another user pokes you.</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -242,19 +242,30 @@
|
|||||||
"clientInfoUnknown": "Unknown",
|
"clientInfoUnknown": "Unknown",
|
||||||
"clientInfoHidden": "Hidden",
|
"clientInfoHidden": "Hidden",
|
||||||
"clientInfoNone": "None",
|
"clientInfoNone": "None",
|
||||||
"pokeSnackBarClearAction": "Clear",
|
"pokeSettingsAction": "Poke notifications",
|
||||||
"pokeSnackBarMoreIndicator": "...",
|
"pokeSettingsTitle": "Poke notifications",
|
||||||
"pokeSnackBarIncomingNoMessage": "{sender} pokes you",
|
"pokeSettingsEnableLabel": "Notify me about pokes",
|
||||||
"@pokeSnackBarIncomingNoMessage": {
|
"pokeSettingsEnableDescription": "Show local notifications for incoming pokes when this is on.",
|
||||||
|
"pokeSettingsMutedSendersHeader": "Muted senders",
|
||||||
|
"pokeSettingsMutedSendersEmpty": "No muted poke senders.",
|
||||||
|
"pokeSettingsMutedSenderLabel": "Client ID {senderId}",
|
||||||
|
"@pokeSettingsMutedSenderLabel": {
|
||||||
|
"placeholders": {
|
||||||
|
"senderId": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeSettingsUnmuteSenderAction": "Unmute",
|
||||||
|
"pokeOverflowMutePrompt": "Repeated pokes from {sender} were suppressed. Mute this sender?",
|
||||||
|
"@pokeOverflowMutePrompt": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"sender": { "type": "String" }
|
"sender": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pokeSnackBarIncomingWithMessage": "{sender} pokes you: {message}",
|
"pokeOverflowMuteAction": "Mute",
|
||||||
"@pokeSnackBarIncomingWithMessage": {
|
"pokeMutedSenderConfirmation": "Muted pokes from {sender}",
|
||||||
|
"@pokeMutedSenderConfirmation": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"sender": { "type": "String" },
|
"sender": { "type": "String" }
|
||||||
"message": { "type": "String" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".",
|
"pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".",
|
||||||
|
|||||||
@@ -191,19 +191,30 @@
|
|||||||
"clientInfoUnknown": "未知",
|
"clientInfoUnknown": "未知",
|
||||||
"clientInfoHidden": "隐藏",
|
"clientInfoHidden": "隐藏",
|
||||||
"clientInfoNone": "无",
|
"clientInfoNone": "无",
|
||||||
"pokeSnackBarClearAction": "清除",
|
"pokeSettingsAction": "戳一戳通知",
|
||||||
"pokeSnackBarMoreIndicator": "...",
|
"pokeSettingsTitle": "戳一戳通知",
|
||||||
"pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下",
|
"pokeSettingsEnableLabel": "接收戳一戳通知",
|
||||||
"@pokeSnackBarIncomingNoMessage": {
|
"pokeSettingsEnableDescription": "开启后,收到戳一戳时会显示本地通知。",
|
||||||
|
"pokeSettingsMutedSendersHeader": "已静音的发送者",
|
||||||
|
"pokeSettingsMutedSendersEmpty": "没有已静音的戳一戳发送者。",
|
||||||
|
"pokeSettingsMutedSenderLabel": "用户 ID {senderId}",
|
||||||
|
"@pokeSettingsMutedSenderLabel": {
|
||||||
|
"placeholders": {
|
||||||
|
"senderId": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeSettingsUnmuteSenderAction": "取消静音",
|
||||||
|
"pokeOverflowMutePrompt": "来自 {sender} 的重复戳一戳已被抑制。要静音此发送者吗?",
|
||||||
|
"@pokeOverflowMutePrompt": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"sender": { "type": "String" }
|
"sender": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pokeSnackBarIncomingWithMessage": "{sender} 戳了你一下:{message}",
|
"pokeOverflowMuteAction": "静音",
|
||||||
"@pokeSnackBarIncomingWithMessage": {
|
"pokeMutedSenderConfirmation": "已静音来自 {sender} 的戳一戳",
|
||||||
|
"@pokeMutedSenderConfirmation": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"sender": { "type": "String" },
|
"sender": { "type": "String" }
|
||||||
"message": { "type": "String" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。",
|
"pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。",
|
||||||
|
|||||||
@@ -1159,29 +1159,71 @@ abstract class AppL10n {
|
|||||||
/// **'None'**
|
/// **'None'**
|
||||||
String get clientInfoNone;
|
String get clientInfoNone;
|
||||||
|
|
||||||
/// No description provided for @pokeSnackBarClearAction.
|
/// No description provided for @pokeSettingsAction.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Clear'**
|
/// **'Poke notifications'**
|
||||||
String get pokeSnackBarClearAction;
|
String get pokeSettingsAction;
|
||||||
|
|
||||||
/// No description provided for @pokeSnackBarMoreIndicator.
|
/// No description provided for @pokeSettingsTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'...'**
|
/// **'Poke notifications'**
|
||||||
String get pokeSnackBarMoreIndicator;
|
String get pokeSettingsTitle;
|
||||||
|
|
||||||
/// No description provided for @pokeSnackBarIncomingNoMessage.
|
/// No description provided for @pokeSettingsEnableLabel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'{sender} pokes you'**
|
/// **'Notify me about pokes'**
|
||||||
String pokeSnackBarIncomingNoMessage(String sender);
|
String get pokeSettingsEnableLabel;
|
||||||
|
|
||||||
/// No description provided for @pokeSnackBarIncomingWithMessage.
|
/// No description provided for @pokeSettingsEnableDescription.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'{sender} pokes you: {message}'**
|
/// **'Show local notifications for incoming pokes when this is on.'**
|
||||||
String pokeSnackBarIncomingWithMessage(String sender, String message);
|
String get pokeSettingsEnableDescription;
|
||||||
|
|
||||||
|
/// No description provided for @pokeSettingsMutedSendersHeader.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Muted senders'**
|
||||||
|
String get pokeSettingsMutedSendersHeader;
|
||||||
|
|
||||||
|
/// No description provided for @pokeSettingsMutedSendersEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No muted poke senders.'**
|
||||||
|
String get pokeSettingsMutedSendersEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @pokeSettingsMutedSenderLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Client ID {senderId}'**
|
||||||
|
String pokeSettingsMutedSenderLabel(String senderId);
|
||||||
|
|
||||||
|
/// No description provided for @pokeSettingsUnmuteSenderAction.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unmute'**
|
||||||
|
String get pokeSettingsUnmuteSenderAction;
|
||||||
|
|
||||||
|
/// No description provided for @pokeOverflowMutePrompt.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Repeated pokes from {sender} were suppressed. Mute this sender?'**
|
||||||
|
String pokeOverflowMutePrompt(String sender);
|
||||||
|
|
||||||
|
/// No description provided for @pokeOverflowMuteAction.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Mute'**
|
||||||
|
String get pokeOverflowMuteAction;
|
||||||
|
|
||||||
|
/// No description provided for @pokeMutedSenderConfirmation.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Muted pokes from {sender}'**
|
||||||
|
String pokeMutedSenderConfirmation(String sender);
|
||||||
|
|
||||||
/// No description provided for @pokeHistorySelfNoMessage.
|
/// No description provided for @pokeHistorySelfNoMessage.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -590,19 +590,43 @@ class AppL10nEn extends AppL10n {
|
|||||||
String get clientInfoNone => 'None';
|
String get clientInfoNone => 'None';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pokeSnackBarClearAction => 'Clear';
|
String get pokeSettingsAction => 'Poke notifications';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pokeSnackBarMoreIndicator => '...';
|
String get pokeSettingsTitle => 'Poke notifications';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String pokeSnackBarIncomingNoMessage(String sender) {
|
String get pokeSettingsEnableLabel => 'Notify me about pokes';
|
||||||
return '$sender pokes you';
|
|
||||||
|
@override
|
||||||
|
String get pokeSettingsEnableDescription =>
|
||||||
|
'Show local notifications for incoming pokes when this is on.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeSettingsMutedSendersHeader => 'Muted senders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeSettingsMutedSendersEmpty => 'No muted poke senders.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeSettingsMutedSenderLabel(String senderId) {
|
||||||
|
return 'Client ID $senderId';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String pokeSnackBarIncomingWithMessage(String sender, String message) {
|
String get pokeSettingsUnmuteSenderAction => 'Unmute';
|
||||||
return '$sender pokes you: $message';
|
|
||||||
|
@override
|
||||||
|
String pokeOverflowMutePrompt(String sender) {
|
||||||
|
return 'Repeated pokes from $sender were suppressed. Mute this sender?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeOverflowMuteAction => 'Mute';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeMutedSenderConfirmation(String sender) {
|
||||||
|
return 'Muted pokes from $sender';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -577,19 +577,42 @@ class AppL10nZh extends AppL10n {
|
|||||||
String get clientInfoNone => '无';
|
String get clientInfoNone => '无';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pokeSnackBarClearAction => '清除';
|
String get pokeSettingsAction => '戳一戳通知';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pokeSnackBarMoreIndicator => '...';
|
String get pokeSettingsTitle => '戳一戳通知';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String pokeSnackBarIncomingNoMessage(String sender) {
|
String get pokeSettingsEnableLabel => '接收戳一戳通知';
|
||||||
return '$sender 戳了你一下';
|
|
||||||
|
@override
|
||||||
|
String get pokeSettingsEnableDescription => '开启后,收到戳一戳时会显示本地通知。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeSettingsMutedSendersHeader => '已静音的发送者';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeSettingsMutedSendersEmpty => '没有已静音的戳一戳发送者。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeSettingsMutedSenderLabel(String senderId) {
|
||||||
|
return '用户 ID $senderId';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String pokeSnackBarIncomingWithMessage(String sender, String message) {
|
String get pokeSettingsUnmuteSenderAction => '取消静音';
|
||||||
return '$sender 戳了你一下:$message';
|
|
||||||
|
@override
|
||||||
|
String pokeOverflowMutePrompt(String sender) {
|
||||||
|
return '来自 $sender 的重复戳一戳已被抑制。要静音此发送者吗?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeOverflowMuteAction => '静音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeMutedSenderConfirmation(String sender) {
|
||||||
|
return '已静音来自 $sender 的戳一戳';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
+26
-2704
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
|
import '../l10n/generated/app_localizations.dart';
|
||||||
|
|
||||||
|
class LiveDiagnosticsDialog extends StatefulWidget {
|
||||||
|
const LiveDiagnosticsDialog({super.key, required this.diagnosticsTextBuilder});
|
||||||
|
|
||||||
|
final String Function() diagnosticsTextBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LiveDiagnosticsDialog> createState() => _LiveDiagnosticsDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LiveDiagnosticsDialogState extends State<LiveDiagnosticsDialog> {
|
||||||
|
static const _refreshInterval = Duration(seconds: 1);
|
||||||
|
|
||||||
|
Timer? _refreshTimer;
|
||||||
|
String _text = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_refresh();
|
||||||
|
_refreshTimer = Timer.periodic(_refreshInterval, (_) => _refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_refreshTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refresh() {
|
||||||
|
final next = widget.diagnosticsTextBuilder();
|
||||||
|
if (!mounted || next == _text) return;
|
||||||
|
setState(() => _text = next);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppL10n.of(context);
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(l10n.diagnosticsAction)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Tooltip(
|
||||||
|
message: l10n.diagnosticsLiveUpdating,
|
||||||
|
child: Icon(
|
||||||
|
Icons.sync,
|
||||||
|
size: 18,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: 720,
|
||||||
|
maxHeight: size.height * 0.65,
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SelectableText(
|
||||||
|
_text,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await SharePlus.instance.share(ShareParams(text: _text));
|
||||||
|
},
|
||||||
|
child: Text(l10n.shareAction),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: _text));
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(l10n.copyAction),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(l10n.closeAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,15 +15,22 @@ typedef StorageDirectoryProvider = Future<Directory> Function();
|
|||||||
typedef StorageInitializer = Future<void> Function(String dir);
|
typedef StorageInitializer = Future<void> Function(String dir);
|
||||||
|
|
||||||
Future<void>? _storageInitFuture;
|
Future<void>? _storageInitFuture;
|
||||||
|
Future<void>? _cacheInitFuture;
|
||||||
Future<void>? _vadBootstrapFuture;
|
Future<void>? _vadBootstrapFuture;
|
||||||
StorageDirectoryProvider _storageDirectoryProvider =
|
StorageDirectoryProvider _storageDirectoryProvider =
|
||||||
getApplicationSupportDirectory;
|
getApplicationSupportDirectory;
|
||||||
|
StorageDirectoryProvider _cacheDirectoryProvider = getApplicationCacheDirectory;
|
||||||
StorageInitializer _storageInitializer = _defaultStorageInitializer;
|
StorageInitializer _storageInitializer = _defaultStorageInitializer;
|
||||||
|
StorageInitializer _cacheInitializer = _defaultCacheInitializer;
|
||||||
|
|
||||||
Future<void> _defaultStorageInitializer(String dir) {
|
Future<void> _defaultStorageInitializer(String dir) {
|
||||||
return rust.initStorage(dir: dir);
|
return rust.initStorage(dir: dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _defaultCacheInitializer(String dir) {
|
||||||
|
return rust.initCache(dir: dir);
|
||||||
|
}
|
||||||
|
|
||||||
Future<File> _copyBundledAssetToDocuments({
|
Future<File> _copyBundledAssetToDocuments({
|
||||||
required String assetPath,
|
required String assetPath,
|
||||||
required String fileName,
|
required String fileName,
|
||||||
@@ -121,16 +128,50 @@ Future<void> _wireStorageImpl() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> wireCache() async {
|
||||||
|
final existing = _cacheInitFuture;
|
||||||
|
if (existing != null) {
|
||||||
|
await existing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final initFuture = _wireCacheImpl();
|
||||||
|
_cacheInitFuture = initFuture;
|
||||||
|
await initFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _wireCacheImpl() async {
|
||||||
|
var initialized = false;
|
||||||
|
try {
|
||||||
|
final dir = await _cacheDirectoryProvider();
|
||||||
|
await _cacheInitializer(dir.path);
|
||||||
|
initialized = true;
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort; missing cache just means protocol-owned assets are
|
||||||
|
// re-downloaded this session.
|
||||||
|
} finally {
|
||||||
|
if (!initialized) {
|
||||||
|
_cacheInitFuture = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void debugResetStorageBootstrap({
|
void debugResetStorageBootstrap({
|
||||||
StorageDirectoryProvider? storageDirectoryProvider,
|
StorageDirectoryProvider? storageDirectoryProvider,
|
||||||
|
StorageDirectoryProvider? cacheDirectoryProvider,
|
||||||
StorageInitializer? storageInitializer,
|
StorageInitializer? storageInitializer,
|
||||||
|
StorageInitializer? cacheInitializer,
|
||||||
}) {
|
}) {
|
||||||
_storageInitFuture = null;
|
_storageInitFuture = null;
|
||||||
|
_cacheInitFuture = null;
|
||||||
_vadBootstrapFuture = null;
|
_vadBootstrapFuture = null;
|
||||||
_storageDirectoryProvider =
|
_storageDirectoryProvider =
|
||||||
storageDirectoryProvider ?? getApplicationSupportDirectory;
|
storageDirectoryProvider ?? getApplicationSupportDirectory;
|
||||||
|
_cacheDirectoryProvider =
|
||||||
|
cacheDirectoryProvider ?? getApplicationCacheDirectory;
|
||||||
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
|
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
|
||||||
|
_cacheInitializer = cacheInitializer ?? _defaultCacheInitializer;
|
||||||
}
|
}
|
||||||
|
|
||||||
rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
|
rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
|
||||||
|
|||||||
@@ -148,12 +148,13 @@ void wireMacosAudioLifecycle({
|
|||||||
try {
|
try {
|
||||||
switch (call.method) {
|
switch (call.method) {
|
||||||
case 'handleDefaultDeviceChange':
|
case 'handleDefaultDeviceChange':
|
||||||
// TODO: call rust.macosDefaultDeviceChanged() once exposed
|
// TRACKED(macos-device-change): call rust.macosDefaultDeviceChanged()
|
||||||
// via flutter_rust_bridge; until then the event is captured
|
// once exposed via flutter_rust_bridge; until then the event is
|
||||||
// here for observability.
|
// captured here for observability.
|
||||||
break;
|
break;
|
||||||
case 'handleConfigurationChange':
|
case 'handleConfigurationChange':
|
||||||
// TODO: same — currently captured, no engine action yet.
|
// TRACKED(macos-config-change): currently captured, no engine action
|
||||||
|
// yet — depends on Rust-side device-change API exposure.
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
class HardMuteOwners {
|
||||||
|
const HardMuteOwners({
|
||||||
|
this.manual = false,
|
||||||
|
this.permission = false,
|
||||||
|
this.talkPower = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool manual;
|
||||||
|
final bool permission;
|
||||||
|
final bool talkPower;
|
||||||
|
|
||||||
|
bool get effective => manual || permission || talkPower;
|
||||||
|
|
||||||
|
HardMuteOwners withBridgeManualMute(bool muted) {
|
||||||
|
return copyWith(
|
||||||
|
manual: muted && (manual || !permission && !talkPower),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HardMuteOwners copyWith({
|
||||||
|
bool? manual,
|
||||||
|
bool? permission,
|
||||||
|
bool? talkPower,
|
||||||
|
}) {
|
||||||
|
return HardMuteOwners(
|
||||||
|
manual: manual ?? this.manual,
|
||||||
|
permission: permission ?? this.permission,
|
||||||
|
talkPower: talkPower ?? this.talkPower,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
const iosAudioSessionChannelName = 'chanora/ios_audio_session';
|
||||||
|
|
||||||
|
/// Controls the iOS AVAudioSession VoIP lifecycle from Dart.
|
||||||
|
///
|
||||||
|
/// The Swift `AppDelegate` configures the session to `.ambient` at
|
||||||
|
/// launch and leaves it inactive. The session is only switched to
|
||||||
|
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
|
||||||
|
/// voice channel is actually active. This controller is the Dart
|
||||||
|
/// side of that contract — call [activate] before the Rust engine
|
||||||
|
/// starts VoiceProcessingIO and [deactivate] on
|
||||||
|
/// `BridgeEvent::AudioStopped` or failed joins.
|
||||||
|
///
|
||||||
|
/// On non-iOS platforms both methods are no-ops; the platforms
|
||||||
|
/// handle their own session lifecycle elsewhere (Android via
|
||||||
|
/// `AndroidAudioLifecycleController`, macOS via
|
||||||
|
/// `MacOSAudioLifecycle`, desktop has no exclusive session).
|
||||||
|
class IosAudioSessionController {
|
||||||
|
IosAudioSessionController({
|
||||||
|
MethodChannel? channel,
|
||||||
|
bool? isIos,
|
||||||
|
}) : _channel = channel ?? const MethodChannel(iosAudioSessionChannelName),
|
||||||
|
_isIos = isIos ?? Platform.isIOS;
|
||||||
|
|
||||||
|
final MethodChannel _channel;
|
||||||
|
final bool _isIos;
|
||||||
|
|
||||||
|
Future<void> activate() async {
|
||||||
|
if (!_isIos) return;
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>('activateVoiceSession');
|
||||||
|
} on PlatformException {
|
||||||
|
// Swift side logs the failure via NSLog; surfacing the
|
||||||
|
// exception to the event handler would be noise. The Rust
|
||||||
|
// engine remains alive and will produce silence until the
|
||||||
|
// next route change or a manual leave/rejoin.
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Test hosts and mispackaged builds may not have registered
|
||||||
|
// the iOS channel. Keep event dispatch alive rather than
|
||||||
|
// surfacing an unhandled async error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deactivate() async {
|
||||||
|
if (!_isIos) return;
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>('deactivateVoiceSession');
|
||||||
|
} on PlatformException {
|
||||||
|
// Same rationale as activate(): the Swift side logs.
|
||||||
|
// Worst case the session stays in .playAndRecord until the
|
||||||
|
// app is backgrounded — at which point iOS reclaims the
|
||||||
|
// session automatically.
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Same rationale as activate(): missing channel should not
|
||||||
|
// break bridge event handling.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default singleton used by [main.dart] event dispatch. Tests
|
||||||
|
/// should construct their own [IosAudioSessionController] with a
|
||||||
|
/// mocked channel rather than mutating this instance.
|
||||||
|
final iosAudioSessionController = IosAudioSessionController();
|
||||||
@@ -3,11 +3,19 @@ import 'package:flutter/material.dart';
|
|||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Manages user-trusted link domains to suppress external-link warnings.
|
||||||
|
///
|
||||||
|
/// Trusted domains are persisted in [SharedPreferences] under
|
||||||
|
/// `'trusted_domains'`. Supports wildcard patterns (e.g. `'*.example.com'`)
|
||||||
|
/// that match any subdomain of the base host.
|
||||||
|
///
|
||||||
|
/// This is a singleton; use [LinkTrustService.instance] to obtain it.
|
||||||
class LinkTrustService extends ChangeNotifier {
|
class LinkTrustService extends ChangeNotifier {
|
||||||
static LinkTrustService? _instance;
|
static LinkTrustService? _instance;
|
||||||
final Set<String> _trusted = {};
|
final Set<String> _trusted = {};
|
||||||
bool _loaded = false;
|
bool _loaded = false;
|
||||||
|
|
||||||
|
/// Returns the singleton [LinkTrustService] instance.
|
||||||
static LinkTrustService get instance {
|
static LinkTrustService get instance {
|
||||||
_instance ??= LinkTrustService._();
|
_instance ??= LinkTrustService._();
|
||||||
return _instance!;
|
return _instance!;
|
||||||
@@ -26,6 +34,10 @@ class LinkTrustService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if [host] matches any trusted domain pattern.
|
||||||
|
///
|
||||||
|
/// Matching is case-insensitive. Wildcard patterns like `'*.example.com'`
|
||||||
|
/// match both `example.com` and any `*.example.com` subdomain.
|
||||||
bool isTrusted(String host) {
|
bool isTrusted(String host) {
|
||||||
host = host.toLowerCase();
|
host = host.toLowerCase();
|
||||||
for (final pattern in _trusted) {
|
for (final pattern in _trusted) {
|
||||||
@@ -34,6 +46,7 @@ class LinkTrustService extends ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists [host] as a trusted domain and notifies listeners.
|
||||||
Future<void> addTrustedDomain(String host) async {
|
Future<void> addTrustedDomain(String host) async {
|
||||||
host = host.toLowerCase();
|
host = host.toLowerCase();
|
||||||
_trusted.add(host);
|
_trusted.add(host);
|
||||||
@@ -51,6 +64,10 @@ class LinkTrustService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows a dialog asking the user whether to open an external link.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the user chose to open and checked "remember this domain",
|
||||||
|
/// `false` if the user chose to open without remembering, or `null` if cancelled.
|
||||||
Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
|
Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
|
||||||
bool remember = false;
|
bool remember = false;
|
||||||
return showDialog<bool>(
|
return showDialog<bool>(
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
|
/// Manages local push notifications for TeamSpeak poke events.
|
||||||
|
///
|
||||||
|
/// Handles platform-specific notification configuration across Android,
|
||||||
|
/// iOS, macOS, Linux, and Windows. Notification sound is intentionally
|
||||||
|
/// delegated to [EventSoundService] (tracked: TODO-event-sounds); this
|
||||||
|
/// service only manages the visual notification surface.
|
||||||
|
///
|
||||||
|
/// Poke strength maps to platform-appropriate urgency levels:
|
||||||
|
/// - [BridgePokeStrength.strong] → high-priority / time-sensitive
|
||||||
|
/// - [BridgePokeStrength.suppressed] → normal priority
|
||||||
|
/// - [BridgePokeStrength.suppressedOverflow] → passive / low priority
|
||||||
|
class PokeNotificationService {
|
||||||
|
/// Creates a [PokeNotificationService] with an optional
|
||||||
|
/// [FlutterLocalNotificationsPlugin] for testing.
|
||||||
|
PokeNotificationService({FlutterLocalNotificationsPlugin? notifications})
|
||||||
|
: _notifications = notifications ?? FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
|
static const _strongAndroidChannelId = 'chanora_pokes_strong_v1';
|
||||||
|
static const _defaultAndroidChannelId = 'chanora_pokes_default_v1';
|
||||||
|
static const _groupKey = 'chanora.pokes';
|
||||||
|
static const _darwinThreadId = 'chanora.pokes';
|
||||||
|
static const _windowsHeader = WindowsHeader(
|
||||||
|
id: 'chanora.pokes',
|
||||||
|
title: 'Pokes',
|
||||||
|
arguments: 'pokes',
|
||||||
|
);
|
||||||
|
static const _windowsAppUserModelId = 'Chanora.Client';
|
||||||
|
static const _windowsGuid = '6B7F3DCB-4418-4E0A-8CC7-02B7C95B675E';
|
||||||
|
|
||||||
|
final FlutterLocalNotificationsPlugin _notifications;
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
/// Initializes the notification plugin with platform-specific settings.
|
||||||
|
///
|
||||||
|
/// Safe to call multiple times; subsequent calls are no-ops.
|
||||||
|
Future<void> init() async {
|
||||||
|
if (_initialized) return;
|
||||||
|
await _notifications.initialize(
|
||||||
|
settings: const InitializationSettings(
|
||||||
|
android: AndroidInitializationSettings('ic_chanora_notification'),
|
||||||
|
iOS: DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: false,
|
||||||
|
requestBadgePermission: false,
|
||||||
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
|
requestSoundPermission: false,
|
||||||
|
defaultPresentSound: false,
|
||||||
|
),
|
||||||
|
macOS: DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: false,
|
||||||
|
requestBadgePermission: false,
|
||||||
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
|
requestSoundPermission: false,
|
||||||
|
defaultPresentSound: false,
|
||||||
|
),
|
||||||
|
linux: LinuxInitializationSettings(
|
||||||
|
defaultActionName: 'Open',
|
||||||
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
|
defaultSuppressSound: true,
|
||||||
|
),
|
||||||
|
windows: WindowsInitializationSettings(
|
||||||
|
appName: 'Chanora',
|
||||||
|
appUserModelId: _windowsAppUserModelId,
|
||||||
|
guid: _windowsGuid,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests notification permission from the user on the current platform.
|
||||||
|
///
|
||||||
|
/// Returns `true` on platforms where permission is not required (web,
|
||||||
|
/// Linux, Windows) or when the user grants permission.
|
||||||
|
Future<bool> requestPermission() async {
|
||||||
|
await init();
|
||||||
|
if (kIsWeb) return true;
|
||||||
|
|
||||||
|
final android = _notifications
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
|
>();
|
||||||
|
if (android != null) {
|
||||||
|
return await android.requestNotificationsPermission() ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ios = _notifications
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
IOSFlutterLocalNotificationsPlugin
|
||||||
|
>();
|
||||||
|
if (ios != null) {
|
||||||
|
return await ios.requestPermissions(alert: true, badge: true) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final macOS = _notifications
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
MacOSFlutterLocalNotificationsPlugin
|
||||||
|
>();
|
||||||
|
if (macOS != null) {
|
||||||
|
return await macOS.requestPermissions(alert: true, badge: true) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Displays a poke notification from [senderName] with [strength]-based
|
||||||
|
/// urgency. Silently returns if the user has denied notification permission.
|
||||||
|
Future<void> show({
|
||||||
|
required String senderName,
|
||||||
|
required String message,
|
||||||
|
required BigInt senderId,
|
||||||
|
required rust.BridgePokeStrength strength,
|
||||||
|
}) async {
|
||||||
|
await init();
|
||||||
|
final permitted = await requestPermission();
|
||||||
|
if (!permitted) return;
|
||||||
|
|
||||||
|
final trimmedMessage = message.trim();
|
||||||
|
final body = trimmedMessage.isEmpty
|
||||||
|
? '$senderName pokes you'
|
||||||
|
: trimmedMessage;
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
id: senderId.toUnsigned(31).toInt(),
|
||||||
|
title: 'Poke from $senderName',
|
||||||
|
body: body,
|
||||||
|
notificationDetails: NotificationDetails(
|
||||||
|
android: _androidDetails(strength),
|
||||||
|
iOS: _darwinDetails(strength),
|
||||||
|
macOS: _darwinDetails(strength),
|
||||||
|
linux: _linuxDetails(strength),
|
||||||
|
windows: _windowsDetails(strength),
|
||||||
|
),
|
||||||
|
payload: 'poke:$senderId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidNotificationDetails _androidDetails(rust.BridgePokeStrength strength) {
|
||||||
|
final isStrong = strength == rust.BridgePokeStrength.strong;
|
||||||
|
return AndroidNotificationDetails(
|
||||||
|
isStrong ? _strongAndroidChannelId : _defaultAndroidChannelId,
|
||||||
|
isStrong ? 'Pokes' : 'Pokes (quiet)',
|
||||||
|
channelDescription: 'TeamSpeak poke notifications',
|
||||||
|
importance: isStrong ? Importance.max : Importance.defaultImportance,
|
||||||
|
priority: isStrong ? Priority.high : Priority.defaultPriority,
|
||||||
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
|
playSound: false,
|
||||||
|
silent: true,
|
||||||
|
groupKey: _groupKey,
|
||||||
|
category: AndroidNotificationCategory.message,
|
||||||
|
visibility: NotificationVisibility.private,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DarwinNotificationDetails _darwinDetails(rust.BridgePokeStrength strength) {
|
||||||
|
return DarwinNotificationDetails(
|
||||||
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
|
presentSound: false,
|
||||||
|
threadIdentifier: _darwinThreadId,
|
||||||
|
interruptionLevel: switch (strength) {
|
||||||
|
rust.BridgePokeStrength.strong => InterruptionLevel.timeSensitive,
|
||||||
|
rust.BridgePokeStrength.suppressed => InterruptionLevel.active,
|
||||||
|
rust.BridgePokeStrength.suppressedOverflow => InterruptionLevel.passive,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LinuxNotificationDetails _linuxDetails(rust.BridgePokeStrength strength) {
|
||||||
|
return LinuxNotificationDetails(
|
||||||
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
|
suppressSound: true,
|
||||||
|
urgency: switch (strength) {
|
||||||
|
rust.BridgePokeStrength.strong => LinuxNotificationUrgency.critical,
|
||||||
|
rust.BridgePokeStrength.suppressed => LinuxNotificationUrgency.normal,
|
||||||
|
rust.BridgePokeStrength.suppressedOverflow =>
|
||||||
|
LinuxNotificationUrgency.low,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowsNotificationDetails _windowsDetails(rust.BridgePokeStrength strength) {
|
||||||
|
return WindowsNotificationDetails(
|
||||||
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
|
audio: WindowsNotificationAudio.silent(),
|
||||||
|
header: _windowsHeader,
|
||||||
|
scenario: strength == rust.BridgePokeStrength.strong
|
||||||
|
? WindowsNotificationScenario.urgent
|
||||||
|
: null,
|
||||||
|
duration: strength == rust.BridgePokeStrength.strong
|
||||||
|
? WindowsNotificationDuration.long
|
||||||
|
: WindowsNotificationDuration.short,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Persists user preferences for TeamSpeak poke notifications.
|
||||||
|
///
|
||||||
|
/// Stores two settings:
|
||||||
|
/// - Whether pokes are globally enabled
|
||||||
|
/// - A set of muted sender client IDs
|
||||||
|
///
|
||||||
|
/// Preferences are written to [SharedPreferences] and observable
|
||||||
|
/// through [ValueListenable] so UI widgets can rebuild reactively.
|
||||||
|
class PokePreferencesService {
|
||||||
|
static const _enabledKey = 'pokes.enabled';
|
||||||
|
static const _mutedSendersKey = 'pokes.muted_senders';
|
||||||
|
|
||||||
|
final ValueNotifier<bool> _pokesEnabled = ValueNotifier<bool>(true);
|
||||||
|
final ValueNotifier<Set<BigInt>> _mutedSenders = ValueNotifier<Set<BigInt>>(
|
||||||
|
const <BigInt>{},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Whether poke notifications are globally enabled.
|
||||||
|
ValueListenable<bool> get pokesEnabled => _pokesEnabled;
|
||||||
|
|
||||||
|
/// Set of client IDs whose pokes are muted.
|
||||||
|
ValueListenable<Set<BigInt>> get mutedSenders => _mutedSenders;
|
||||||
|
|
||||||
|
/// Loads persisted preferences from [SharedPreferences].
|
||||||
|
Future<void> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_pokesEnabled.value = prefs.getBool(_enabledKey) ?? true;
|
||||||
|
_mutedSenders.value = (prefs.getStringList(_mutedSendersKey) ?? const [])
|
||||||
|
.map(BigInt.parse)
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables or disables poke notifications globally.
|
||||||
|
Future<void> setPokesEnabled(bool enabled) async {
|
||||||
|
_pokesEnabled.value = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_enabledKey, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds [senderId] to the muted senders set.
|
||||||
|
Future<void> muteSender(BigInt senderId) async {
|
||||||
|
if (_mutedSenders.value.contains(senderId)) return;
|
||||||
|
_mutedSenders.value = {..._mutedSenders.value, senderId};
|
||||||
|
await _saveMutedSenders();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes [senderId] from the muted senders set.
|
||||||
|
Future<void> unmuteSender(BigInt senderId) async {
|
||||||
|
if (!_mutedSenders.value.contains(senderId)) return;
|
||||||
|
_mutedSenders.value = _mutedSenders.value
|
||||||
|
.where((mutedSender) => mutedSender != senderId)
|
||||||
|
.toSet();
|
||||||
|
await _saveMutedSenders();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if [senderId] is in the muted senders set.
|
||||||
|
bool isMuted(BigInt senderId) => _mutedSenders.value.contains(senderId);
|
||||||
|
|
||||||
|
Future<void> _saveMutedSenders() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(
|
||||||
|
_mutedSendersKey,
|
||||||
|
_mutedSenders.value.map((senderId) => senderId.toString()).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases [ValueNotifier] resources.
|
||||||
|
void dispose() {
|
||||||
|
_pokesEnabled.dispose();
|
||||||
|
_mutedSenders.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,11 +18,19 @@ class UiSettings {
|
|||||||
this.host = '',
|
this.host = '',
|
||||||
this.nickname = '',
|
this.nickname = '',
|
||||||
this.themeMode = UiThemeMode.system,
|
this.themeMode = UiThemeMode.system,
|
||||||
|
this.transmitModeIndex,
|
||||||
|
this.releaseTailMs,
|
||||||
|
this.inputDeviceId,
|
||||||
|
this.outputDeviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String host;
|
final String host;
|
||||||
final String nickname;
|
final String nickname;
|
||||||
final UiThemeMode themeMode;
|
final UiThemeMode themeMode;
|
||||||
|
final int? transmitModeIndex;
|
||||||
|
final int? releaseTailMs;
|
||||||
|
final String? inputDeviceId;
|
||||||
|
final String? outputDeviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
class UiPreferencesService {
|
class UiPreferencesService {
|
||||||
@@ -30,6 +38,10 @@ class UiPreferencesService {
|
|||||||
static const _nicknameKey = 'ui.nickname';
|
static const _nicknameKey = 'ui.nickname';
|
||||||
static const _themeModeKey = 'ui.theme_mode';
|
static const _themeModeKey = 'ui.theme_mode';
|
||||||
static const _permissionsExplainedKey = 'perms_explained';
|
static const _permissionsExplainedKey = 'perms_explained';
|
||||||
|
static const _transmitModeIndexKey = 'voice.transmit_mode_index';
|
||||||
|
static const _releaseTailMsKey = 'voice.release_tail_ms';
|
||||||
|
static const _inputDeviceIdKey = 'audio.input_device_id';
|
||||||
|
static const _outputDeviceIdKey = 'audio.output_device_id';
|
||||||
|
|
||||||
const UiPreferencesService();
|
const UiPreferencesService();
|
||||||
|
|
||||||
@@ -39,6 +51,10 @@ class UiPreferencesService {
|
|||||||
host: prefs.getString(_hostKey) ?? '',
|
host: prefs.getString(_hostKey) ?? '',
|
||||||
nickname: prefs.getString(_nicknameKey) ?? '',
|
nickname: prefs.getString(_nicknameKey) ?? '',
|
||||||
themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)),
|
themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)),
|
||||||
|
transmitModeIndex: prefs.getInt(_transmitModeIndexKey),
|
||||||
|
releaseTailMs: prefs.getInt(_releaseTailMsKey),
|
||||||
|
inputDeviceId: prefs.getString(_inputDeviceIdKey),
|
||||||
|
outputDeviceId: prefs.getString(_outputDeviceIdKey),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +69,34 @@ class UiPreferencesService {
|
|||||||
await prefs.setString(_themeModeKey, themeMode.name);
|
await prefs.setString(_themeModeKey, themeMode.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveTransmitModeIndex(int index) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_transmitModeIndexKey, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveReleaseTailMs(int ms) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_releaseTailMsKey, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveInputDeviceId(String? id) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (id == null) {
|
||||||
|
await prefs.remove(_inputDeviceIdKey);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_inputDeviceIdKey, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveOutputDeviceId(String? id) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (id == null) {
|
||||||
|
await prefs.remove(_outputDeviceIdKey);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_outputDeviceIdKey, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> hasExplainedPermissions() async {
|
Future<bool> hasExplainedPermissions() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getBool(_permissionsExplainedKey) ?? false;
|
return prefs.getBool(_permissionsExplainedKey) ?? false;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
typedef VoiceJoinCallback = Future<void> Function({
|
||||||
|
required BigInt channelId,
|
||||||
|
required String password,
|
||||||
|
});
|
||||||
|
|
||||||
|
typedef IosVoiceSessionActivation = Future<void> Function();
|
||||||
|
typedef IosVoiceSessionDeactivation = Future<void> Function();
|
||||||
|
|
||||||
|
/// Predicate used to recognise `voiceJoin` errors that the caller treats as a
|
||||||
|
/// successful join outcome (e.g. the server replied "already in channel").
|
||||||
|
///
|
||||||
|
/// When this returns `true` for a thrown error, the iOS audio session is kept
|
||||||
|
/// active because the user is still considered joined to the channel. The
|
||||||
|
/// error is still rethrown so the caller can run its success-on-already-joined
|
||||||
|
/// branch and update local state.
|
||||||
|
typedef VoiceJoinSuccessPredicate = bool Function(Object error);
|
||||||
|
|
||||||
|
Future<void> joinVoiceChannelWithIosAudioSession({
|
||||||
|
required BigInt channelId,
|
||||||
|
required String password,
|
||||||
|
required VoiceJoinCallback voiceJoin,
|
||||||
|
required IosVoiceSessionActivation activateIosAudioSession,
|
||||||
|
required IosVoiceSessionDeactivation deactivateIosAudioSession,
|
||||||
|
VoiceJoinSuccessPredicate? isJoinSuccess,
|
||||||
|
}) async {
|
||||||
|
await activateIosAudioSession();
|
||||||
|
try {
|
||||||
|
await voiceJoin(channelId: channelId, password: password);
|
||||||
|
} catch (e) {
|
||||||
|
if (isJoinSuccess != null && isJoinSuccess(e)) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
await deactivateIosAudioSession();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ part 'api.freezed.dart';
|
|||||||
|
|
||||||
// These functions are ignored because they are not marked as `pub`: `dispatch_platform_audio_event`, `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `platform_audio_events`, `process`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8`
|
// These functions are ignored because they are not marked as `pub`: `dispatch_platform_audio_event`, `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `platform_audio_events`, `process`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8`
|
||||||
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `PlatformAudioEvent`
|
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `PlatformAudioEvent`
|
||||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
|
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
|
||||||
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
|
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
|
||||||
|
|
||||||
/// Return the platform-conventional log-file path as a string, or
|
/// Return the platform-conventional log-file path as a string, or
|
||||||
@@ -226,6 +226,29 @@ String exportDiagnostics() => RustLib.instance.api.crateApiExportDiagnostics();
|
|||||||
Future<void> initStorage({required String dir}) =>
|
Future<void> initStorage({required String dir}) =>
|
||||||
RustLib.instance.api.crateApiInitStorage(dir: dir);
|
RustLib.instance.api.crateApiInitStorage(dir: dir);
|
||||||
|
|
||||||
|
/// Configure the bridge blob cache root.
|
||||||
|
Future<void> initCache({required String dir}) =>
|
||||||
|
RustLib.instance.api.crateApiInitCache(dir: dir);
|
||||||
|
|
||||||
|
/// Resolve avatar bytes through the bridge.
|
||||||
|
Future<Uint8List?> downloadAvatar({
|
||||||
|
required String avatarHash,
|
||||||
|
required String clientUid,
|
||||||
|
}) => RustLib.instance.api.crateApiDownloadAvatar(
|
||||||
|
avatarHash: avatarHash,
|
||||||
|
clientUid: clientUid,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Resolve icon bytes through the bridge.
|
||||||
|
Future<Uint8List?> downloadIcon({required BigInt iconId}) =>
|
||||||
|
RustLib.instance.api.crateApiDownloadIcon(iconId: iconId);
|
||||||
|
|
||||||
|
/// Purge cached protocol-owned assets.
|
||||||
|
Future<void> clearFileCache() => RustLib.instance.api.crateApiClearFileCache();
|
||||||
|
|
||||||
|
/// Report the configured file-cache size.
|
||||||
|
Future<BigInt> fileCacheSize() => RustLib.instance.api.crateApiFileCacheSize();
|
||||||
|
|
||||||
/// List persisted bookmarks.
|
/// List persisted bookmarks.
|
||||||
Future<List<BridgeBookmark>> listBookmarks() =>
|
Future<List<BridgeBookmark>> listBookmarks() =>
|
||||||
RustLib.instance.api.crateApiListBookmarks();
|
RustLib.instance.api.crateApiListBookmarks();
|
||||||
@@ -263,7 +286,8 @@ Future<BridgeAudioStats> audioStats() =>
|
|||||||
|
|
||||||
/// Subscribe to real-time microphone input level at ~30 Hz.
|
/// Subscribe to real-time microphone input level at ~30 Hz.
|
||||||
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
|
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
|
||||||
/// when the Dart subscriber cancels or the session is dropped.
|
/// when the Dart subscriber cancels, the session is dropped, or
|
||||||
|
/// the session becomes persistently unavailable.
|
||||||
Stream<double> inputLevelStream() =>
|
Stream<double> inputLevelStream() =>
|
||||||
RustLib.instance.api.crateApiInputLevelStream();
|
RustLib.instance.api.crateApiInputLevelStream();
|
||||||
|
|
||||||
@@ -1209,6 +1233,9 @@ sealed class BridgeEvent with _$BridgeEvent {
|
|||||||
|
|
||||||
/// Target scope (server/channel/private/poke).
|
/// Target scope (server/channel/private/poke).
|
||||||
required BridgeMessageTarget target,
|
required BridgeMessageTarget target,
|
||||||
|
|
||||||
|
/// Poke notification strength, present only for poke messages.
|
||||||
|
BridgePokeStrength? pokeStrength,
|
||||||
}) = BridgeEvent_ChatMessage;
|
}) = BridgeEvent_ChatMessage;
|
||||||
|
|
||||||
/// Human-readable server activity surfaced from protocol bookkeeping events.
|
/// Human-readable server activity surfaced from protocol bookkeeping events.
|
||||||
@@ -1219,59 +1246,124 @@ sealed class BridgeEvent with _$BridgeEvent {
|
|||||||
|
|
||||||
/// Audio route changed (speaker/earpiece/BT/wired).
|
/// Audio route changed (speaker/earpiece/BT/wired).
|
||||||
const factory BridgeEvent.audioRouteChanged({
|
const factory BridgeEvent.audioRouteChanged({
|
||||||
|
/// New audio output route.
|
||||||
required BridgeAudioRoute route,
|
required BridgeAudioRoute route,
|
||||||
}) = BridgeEvent_AudioRouteChanged;
|
}) = BridgeEvent_AudioRouteChanged;
|
||||||
|
|
||||||
|
/// A client moved to a different channel.
|
||||||
const factory BridgeEvent.clientMoved({
|
const factory BridgeEvent.clientMoved({
|
||||||
|
/// Unique client identifier.
|
||||||
required BigInt clientId,
|
required BigInt clientId,
|
||||||
|
|
||||||
|
/// Destination channel.
|
||||||
required BigInt newChannelId,
|
required BigInt newChannelId,
|
||||||
}) = BridgeEvent_ClientMoved;
|
}) = BridgeEvent_ClientMoved;
|
||||||
|
|
||||||
|
/// A new client connected.
|
||||||
const factory BridgeEvent.clientJoined({
|
const factory BridgeEvent.clientJoined({
|
||||||
|
/// Unique client identifier.
|
||||||
required BigInt clientId,
|
required BigInt clientId,
|
||||||
|
|
||||||
|
/// Channel the client joined.
|
||||||
required BigInt channelId,
|
required BigInt channelId,
|
||||||
|
|
||||||
|
/// Display nickname.
|
||||||
required String name,
|
required String name,
|
||||||
|
|
||||||
|
/// Microphone muted state.
|
||||||
required bool inputMuted,
|
required bool inputMuted,
|
||||||
|
|
||||||
|
/// Speaker muted state.
|
||||||
required bool outputMuted,
|
required bool outputMuted,
|
||||||
|
|
||||||
|
/// True for server query (bot) clients.
|
||||||
required bool isServerQuery,
|
required bool isServerQuery,
|
||||||
|
|
||||||
|
/// Client's talk power value.
|
||||||
required int talkPower,
|
required int talkPower,
|
||||||
|
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
required bool talkPowerGranted,
|
required bool talkPowerGranted,
|
||||||
}) = BridgeEvent_ClientJoined;
|
}) = BridgeEvent_ClientJoined;
|
||||||
|
|
||||||
|
/// A client disconnected.
|
||||||
const factory BridgeEvent.clientLeft({
|
const factory BridgeEvent.clientLeft({
|
||||||
|
/// Unique client identifier.
|
||||||
required BigInt clientId,
|
required BigInt clientId,
|
||||||
|
|
||||||
|
/// Display nickname at time of disconnect.
|
||||||
required String name,
|
required String name,
|
||||||
}) = BridgeEvent_ClientLeft;
|
}) = BridgeEvent_ClientLeft;
|
||||||
|
|
||||||
|
/// Client properties changed.
|
||||||
const factory BridgeEvent.clientUpdated({
|
const factory BridgeEvent.clientUpdated({
|
||||||
|
/// Unique client identifier.
|
||||||
required BigInt clientId,
|
required BigInt clientId,
|
||||||
|
|
||||||
|
/// Microphone muted state.
|
||||||
required bool inputMuted,
|
required bool inputMuted,
|
||||||
|
|
||||||
|
/// Speaker muted state.
|
||||||
required bool outputMuted,
|
required bool outputMuted,
|
||||||
|
|
||||||
|
/// True for server query (bot) clients.
|
||||||
required bool isServerQuery,
|
required bool isServerQuery,
|
||||||
|
|
||||||
|
/// Client's talk power value.
|
||||||
required int talkPower,
|
required int talkPower,
|
||||||
|
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
required bool talkPowerGranted,
|
required bool talkPowerGranted,
|
||||||
}) = BridgeEvent_ClientUpdated;
|
}) = BridgeEvent_ClientUpdated;
|
||||||
|
|
||||||
|
/// A new channel appeared.
|
||||||
const factory BridgeEvent.channelAdded({
|
const factory BridgeEvent.channelAdded({
|
||||||
|
/// Unique channel identifier.
|
||||||
required BigInt id,
|
required BigInt id,
|
||||||
|
|
||||||
|
/// Parent channel ID.
|
||||||
required BigInt parent,
|
required BigInt parent,
|
||||||
|
|
||||||
|
/// Channel name.
|
||||||
required String name,
|
required String name,
|
||||||
|
|
||||||
|
/// Predecessor channel ID within the same parent (TeamSpeak
|
||||||
|
/// linked-list ordering hint). Zero means first child.
|
||||||
required PlatformInt64 order,
|
required PlatformInt64 order,
|
||||||
|
|
||||||
|
/// Whether the channel requires a password.
|
||||||
required bool hasPassword,
|
required bool hasPassword,
|
||||||
|
|
||||||
|
/// Talk power required to speak; `None` means no restriction.
|
||||||
int? neededTalkPower,
|
int? neededTalkPower,
|
||||||
}) = BridgeEvent_ChannelAdded;
|
}) = BridgeEvent_ChannelAdded;
|
||||||
const factory BridgeEvent.channelRemoved({required BigInt id}) =
|
|
||||||
BridgeEvent_ChannelRemoved;
|
/// A channel was deleted.
|
||||||
const factory BridgeEvent.channelUpdated({
|
const factory BridgeEvent.channelRemoved({
|
||||||
|
/// Channel identifier.
|
||||||
required BigInt id,
|
required BigInt id,
|
||||||
|
}) = BridgeEvent_ChannelRemoved;
|
||||||
|
|
||||||
|
/// Channel properties changed.
|
||||||
|
const factory BridgeEvent.channelUpdated({
|
||||||
|
/// Unique channel identifier.
|
||||||
|
required BigInt id,
|
||||||
|
|
||||||
|
/// Channel name.
|
||||||
required String name,
|
required String name,
|
||||||
|
|
||||||
|
/// Whether the channel requires a password.
|
||||||
required bool hasPassword,
|
required bool hasPassword,
|
||||||
|
|
||||||
|
/// Talk power required to speak; `None` means no restriction.
|
||||||
int? neededTalkPower,
|
int? neededTalkPower,
|
||||||
}) = BridgeEvent_ChannelUpdated;
|
}) = BridgeEvent_ChannelUpdated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bridge iOS voice-processing mode.
|
/// Bridge iOS voice-processing mode.
|
||||||
enum BridgeIosVoiceProcessingMode {
|
enum BridgeIosVoiceProcessingMode {
|
||||||
/// Shipping VPIO path.
|
/// Apple VoiceProcessingIO path.
|
||||||
platformVoiceProcessing,
|
platformVoiceProcessing,
|
||||||
|
|
||||||
/// Experimental Sonora path.
|
|
||||||
sonoraExperimental,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
@@ -1306,6 +1398,18 @@ enum BridgeNetworkState {
|
|||||||
offline,
|
offline,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bridge poke notification strength.
|
||||||
|
enum BridgePokeStrength {
|
||||||
|
/// Poke should be surfaced at full strength.
|
||||||
|
strong,
|
||||||
|
|
||||||
|
/// Poke is rate-limited but below overflow severity.
|
||||||
|
suppressed,
|
||||||
|
|
||||||
|
/// Poke remains suppressed after repeated suppressed pokes.
|
||||||
|
suppressedOverflow,
|
||||||
|
}
|
||||||
|
|
||||||
/// Persisted PTT binding display state for the UI.
|
/// Persisted PTT binding display state for the UI.
|
||||||
class BridgePttBinding {
|
class BridgePttBinding {
|
||||||
/// Stable input category string (`""`, `"keyboard"`, or
|
/// Stable input category string (`""`, `"keyboard"`, or
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ return channelUpdated(_that);case _:
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult Function( String message)? serverActivity,TResult Function( BridgeAudioRoute route)? audioRouteChanged,TResult Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult Function( BigInt clientId, String name)? clientLeft,TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult Function( BigInt id)? channelRemoved,TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,required TResult orElse(),}) {final _that = this;
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength)? chatMessage,TResult Function( String message)? serverActivity,TResult Function( BridgeAudioRoute route)? audioRouteChanged,TResult Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult Function( BigInt clientId, String name)? clientLeft,TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult Function( BigInt id)? channelRemoved,TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,required TResult orElse(),}) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case BridgeEvent_Connected() when connected != null:
|
case BridgeEvent_Connected() when connected != null:
|
||||||
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
|
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
|
||||||
@@ -186,7 +186,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
|
|||||||
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
|
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
|
||||||
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
|
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
|
||||||
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
|
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
|
||||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null:
|
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null:
|
||||||
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
|
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
|
||||||
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null:
|
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null:
|
||||||
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null:
|
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null:
|
||||||
@@ -213,7 +213,7 @@ return channelUpdated(_that.id,_that.name,_that.hasPassword,_that.neededTalkPowe
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target) chatMessage,required TResult Function( String message) serverActivity,required TResult Function( BridgeAudioRoute route) audioRouteChanged,required TResult Function( BigInt clientId, BigInt newChannelId) clientMoved,required TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientJoined,required TResult Function( BigInt clientId, String name) clientLeft,required TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientUpdated,required TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower) channelAdded,required TResult Function( BigInt id) channelRemoved,required TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower) channelUpdated,}) {final _that = this;
|
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength) chatMessage,required TResult Function( String message) serverActivity,required TResult Function( BridgeAudioRoute route) audioRouteChanged,required TResult Function( BigInt clientId, BigInt newChannelId) clientMoved,required TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientJoined,required TResult Function( BigInt clientId, String name) clientLeft,required TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientUpdated,required TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower) channelAdded,required TResult Function( BigInt id) channelRemoved,required TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower) channelUpdated,}) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case BridgeEvent_Connected():
|
case BridgeEvent_Connected():
|
||||||
return connected(_that.serverName);case BridgeEvent_Lost():
|
return connected(_that.serverName);case BridgeEvent_Lost():
|
||||||
@@ -226,7 +226,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
|
|||||||
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState():
|
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState():
|
||||||
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState():
|
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState():
|
||||||
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage():
|
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage():
|
||||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity():
|
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity():
|
||||||
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged():
|
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged():
|
||||||
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved():
|
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved():
|
||||||
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined():
|
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined():
|
||||||
@@ -249,7 +249,7 @@ return channelUpdated(_that.id,_that.name,_that.hasPassword,_that.neededTalkPowe
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult? Function( String message)? serverActivity,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,TResult? Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult? Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult? Function( BigInt clientId, String name)? clientLeft,TResult? Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult? Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult? Function( BigInt id)? channelRemoved,TResult? Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,}) {final _that = this;
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength)? chatMessage,TResult? Function( String message)? serverActivity,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,TResult? Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult? Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult? Function( BigInt clientId, String name)? clientLeft,TResult? Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult? Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult? Function( BigInt id)? channelRemoved,TResult? Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,}) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case BridgeEvent_Connected() when connected != null:
|
case BridgeEvent_Connected() when connected != null:
|
||||||
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
|
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
|
||||||
@@ -262,7 +262,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
|
|||||||
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
|
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
|
||||||
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
|
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
|
||||||
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
|
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
|
||||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null:
|
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null:
|
||||||
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
|
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
|
||||||
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null:
|
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null:
|
||||||
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null:
|
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null:
|
||||||
@@ -929,7 +929,7 @@ as PermissionStateKind,
|
|||||||
|
|
||||||
|
|
||||||
class BridgeEvent_ChatMessage extends BridgeEvent {
|
class BridgeEvent_ChatMessage extends BridgeEvent {
|
||||||
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target}): super._();
|
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target, this.pokeStrength}): super._();
|
||||||
|
|
||||||
|
|
||||||
/// Client id of the sender.
|
/// Client id of the sender.
|
||||||
@@ -940,6 +940,8 @@ class BridgeEvent_ChatMessage extends BridgeEvent {
|
|||||||
final String message;
|
final String message;
|
||||||
/// Target scope (server/channel/private/poke).
|
/// Target scope (server/channel/private/poke).
|
||||||
final BridgeMessageTarget target;
|
final BridgeMessageTarget target;
|
||||||
|
/// Poke notification strength, present only for poke messages.
|
||||||
|
final BridgePokeStrength? pokeStrength;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@@ -951,16 +953,16 @@ $BridgeEvent_ChatMessageCopyWith<BridgeEvent_ChatMessage> get copyWith => _$Brid
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ChatMessage&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.senderName, senderName) || other.senderName == senderName)&&(identical(other.message, message) || other.message == message)&&(identical(other.target, target) || other.target == target));
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ChatMessage&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.senderName, senderName) || other.senderName == senderName)&&(identical(other.message, message) || other.message == message)&&(identical(other.target, target) || other.target == target)&&(identical(other.pokeStrength, pokeStrength) || other.pokeStrength == pokeStrength));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target);
|
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target,pokeStrength);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target)';
|
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target, pokeStrength: $pokeStrength)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -971,7 +973,7 @@ abstract mixin class $BridgeEvent_ChatMessageCopyWith<$Res> implements $BridgeEv
|
|||||||
factory $BridgeEvent_ChatMessageCopyWith(BridgeEvent_ChatMessage value, $Res Function(BridgeEvent_ChatMessage) _then) = _$BridgeEvent_ChatMessageCopyWithImpl;
|
factory $BridgeEvent_ChatMessageCopyWith(BridgeEvent_ChatMessage value, $Res Function(BridgeEvent_ChatMessage) _then) = _$BridgeEvent_ChatMessageCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
BigInt senderId, String senderName, String message, BridgeMessageTarget target
|
BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -988,13 +990,14 @@ class _$BridgeEvent_ChatMessageCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,}) {
|
@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,Object? pokeStrength = freezed,}) {
|
||||||
return _then(BridgeEvent_ChatMessage(
|
return _then(BridgeEvent_ChatMessage(
|
||||||
senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable
|
senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable
|
||||||
as BigInt,senderName: null == senderName ? _self.senderName : senderName // ignore: cast_nullable_to_non_nullable
|
as BigInt,senderName: null == senderName ? _self.senderName : senderName // ignore: cast_nullable_to_non_nullable
|
||||||
as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
||||||
as String,target: null == target ? _self.target : target // ignore: cast_nullable_to_non_nullable
|
as String,target: null == target ? _self.target : target // ignore: cast_nullable_to_non_nullable
|
||||||
as BridgeMessageTarget,
|
as BridgeMessageTarget,pokeStrength: freezed == pokeStrength ? _self.pokeStrength : pokeStrength // ignore: cast_nullable_to_non_nullable
|
||||||
|
as BridgePokeStrength?,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1084,6 +1087,7 @@ class BridgeEvent_AudioRouteChanged extends BridgeEvent {
|
|||||||
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
|
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// New audio output route.
|
||||||
final BridgeAudioRoute route;
|
final BridgeAudioRoute route;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1150,7 +1154,9 @@ class BridgeEvent_ClientMoved extends BridgeEvent {
|
|||||||
const BridgeEvent_ClientMoved({required this.clientId, required this.newChannelId}): super._();
|
const BridgeEvent_ClientMoved({required this.clientId, required this.newChannelId}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Unique client identifier.
|
||||||
final BigInt clientId;
|
final BigInt clientId;
|
||||||
|
/// Destination channel.
|
||||||
final BigInt newChannelId;
|
final BigInt newChannelId;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1218,13 +1224,21 @@ class BridgeEvent_ClientJoined extends BridgeEvent {
|
|||||||
const BridgeEvent_ClientJoined({required this.clientId, required this.channelId, required this.name, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._();
|
const BridgeEvent_ClientJoined({required this.clientId, required this.channelId, required this.name, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Unique client identifier.
|
||||||
final BigInt clientId;
|
final BigInt clientId;
|
||||||
|
/// Channel the client joined.
|
||||||
final BigInt channelId;
|
final BigInt channelId;
|
||||||
|
/// Display nickname.
|
||||||
final String name;
|
final String name;
|
||||||
|
/// Microphone muted state.
|
||||||
final bool inputMuted;
|
final bool inputMuted;
|
||||||
|
/// Speaker muted state.
|
||||||
final bool outputMuted;
|
final bool outputMuted;
|
||||||
|
/// True for server query (bot) clients.
|
||||||
final bool isServerQuery;
|
final bool isServerQuery;
|
||||||
|
/// Client's talk power value.
|
||||||
final int talkPower;
|
final int talkPower;
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
final bool talkPowerGranted;
|
final bool talkPowerGranted;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1298,7 +1312,9 @@ class BridgeEvent_ClientLeft extends BridgeEvent {
|
|||||||
const BridgeEvent_ClientLeft({required this.clientId, required this.name}): super._();
|
const BridgeEvent_ClientLeft({required this.clientId, required this.name}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Unique client identifier.
|
||||||
final BigInt clientId;
|
final BigInt clientId;
|
||||||
|
/// Display nickname at time of disconnect.
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1366,11 +1382,17 @@ class BridgeEvent_ClientUpdated extends BridgeEvent {
|
|||||||
const BridgeEvent_ClientUpdated({required this.clientId, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._();
|
const BridgeEvent_ClientUpdated({required this.clientId, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Unique client identifier.
|
||||||
final BigInt clientId;
|
final BigInt clientId;
|
||||||
|
/// Microphone muted state.
|
||||||
final bool inputMuted;
|
final bool inputMuted;
|
||||||
|
/// Speaker muted state.
|
||||||
final bool outputMuted;
|
final bool outputMuted;
|
||||||
|
/// True for server query (bot) clients.
|
||||||
final bool isServerQuery;
|
final bool isServerQuery;
|
||||||
|
/// Client's talk power value.
|
||||||
final int talkPower;
|
final int talkPower;
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
final bool talkPowerGranted;
|
final bool talkPowerGranted;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1442,11 +1464,18 @@ class BridgeEvent_ChannelAdded extends BridgeEvent {
|
|||||||
const BridgeEvent_ChannelAdded({required this.id, required this.parent, required this.name, required this.order, required this.hasPassword, this.neededTalkPower}): super._();
|
const BridgeEvent_ChannelAdded({required this.id, required this.parent, required this.name, required this.order, required this.hasPassword, this.neededTalkPower}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Unique channel identifier.
|
||||||
final BigInt id;
|
final BigInt id;
|
||||||
|
/// Parent channel ID.
|
||||||
final BigInt parent;
|
final BigInt parent;
|
||||||
|
/// Channel name.
|
||||||
final String name;
|
final String name;
|
||||||
|
/// Predecessor channel ID within the same parent (TeamSpeak
|
||||||
|
/// linked-list ordering hint). Zero means first child.
|
||||||
final PlatformInt64 order;
|
final PlatformInt64 order;
|
||||||
|
/// Whether the channel requires a password.
|
||||||
final bool hasPassword;
|
final bool hasPassword;
|
||||||
|
/// Talk power required to speak; `None` means no restriction.
|
||||||
final int? neededTalkPower;
|
final int? neededTalkPower;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1518,6 +1547,7 @@ class BridgeEvent_ChannelRemoved extends BridgeEvent {
|
|||||||
const BridgeEvent_ChannelRemoved({required this.id}): super._();
|
const BridgeEvent_ChannelRemoved({required this.id}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Channel identifier.
|
||||||
final BigInt id;
|
final BigInt id;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1584,9 +1614,13 @@ class BridgeEvent_ChannelUpdated extends BridgeEvent {
|
|||||||
const BridgeEvent_ChannelUpdated({required this.id, required this.name, required this.hasPassword, this.neededTalkPower}): super._();
|
const BridgeEvent_ChannelUpdated({required this.id, required this.name, required this.hasPassword, this.neededTalkPower}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Unique channel identifier.
|
||||||
final BigInt id;
|
final BigInt id;
|
||||||
|
/// Channel name.
|
||||||
final String name;
|
final String name;
|
||||||
|
/// Whether the channel requires a password.
|
||||||
final bool hasPassword;
|
final bool hasPassword;
|
||||||
|
/// Talk power required to speak; `None` means no restriction.
|
||||||
final int? neededTalkPower;
|
final int? neededTalkPower;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
|||||||
String get codegenVersion => '2.12.0';
|
String get codegenVersion => '2.12.0';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get rustContentHash => -20394775;
|
int get rustContentHash => 635684021;
|
||||||
|
|
||||||
static const kDefaultExternalLibraryLoaderConfig =
|
static const kDefaultExternalLibraryLoaderConfig =
|
||||||
ExternalLibraryLoaderConfig(
|
ExternalLibraryLoaderConfig(
|
||||||
@@ -87,6 +87,8 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
Future<void> crateApiBridgeInit();
|
Future<void> crateApiBridgeInit();
|
||||||
|
|
||||||
|
Future<void> crateApiClearFileCache();
|
||||||
|
|
||||||
Future<BridgeClientProfile> crateApiClientProfile({required BigInt clientId});
|
Future<BridgeClientProfile> crateApiClientProfile({required BigInt clientId});
|
||||||
|
|
||||||
Future<BridgeSnapshot> crateApiConnect({
|
Future<BridgeSnapshot> crateApiConnect({
|
||||||
@@ -99,12 +101,21 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
Future<void> crateApiDisconnect();
|
Future<void> crateApiDisconnect();
|
||||||
|
|
||||||
|
Future<Uint8List?> crateApiDownloadAvatar({
|
||||||
|
required String avatarHash,
|
||||||
|
required String clientUid,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<Uint8List?> crateApiDownloadIcon({required BigInt iconId});
|
||||||
|
|
||||||
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled});
|
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled});
|
||||||
|
|
||||||
Stream<BridgeEvent> crateApiEventsStream();
|
Stream<BridgeEvent> crateApiEventsStream();
|
||||||
|
|
||||||
String crateApiExportDiagnostics();
|
String crateApiExportDiagnostics();
|
||||||
|
|
||||||
|
Future<BigInt> crateApiFileCacheSize();
|
||||||
|
|
||||||
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig();
|
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig();
|
||||||
|
|
||||||
Future<BridgePttBinding> crateApiGetPttBinding();
|
Future<BridgePttBinding> crateApiGetPttBinding();
|
||||||
@@ -121,6 +132,8 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
void crateApiHandleRouteChange({required BridgeAudioRoute route});
|
void crateApiHandleRouteChange({required BridgeAudioRoute route});
|
||||||
|
|
||||||
|
Future<void> crateApiInitCache({required String dir});
|
||||||
|
|
||||||
Future<void> crateApiInitStorage({required String dir});
|
Future<void> crateApiInitStorage({required String dir});
|
||||||
|
|
||||||
Stream<double> crateApiInputLevelStream();
|
Stream<double> crateApiInputLevelStream();
|
||||||
@@ -320,6 +333,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiBridgeInitConstMeta =>
|
TaskConstMeta get kCrateApiBridgeInitConstMeta =>
|
||||||
const TaskConstMeta(debugName: "bridge_init", argNames: []);
|
const TaskConstMeta(debugName: "bridge_init", argNames: []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> crateApiClearFileCache() {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 5,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_unit,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiClearFileCacheConstMeta,
|
||||||
|
argValues: [],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiClearFileCacheConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "clear_file_cache", argNames: []);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<BridgeClientProfile> crateApiClientProfile({
|
Future<BridgeClientProfile> crateApiClientProfile({
|
||||||
required BigInt clientId,
|
required BigInt clientId,
|
||||||
@@ -332,7 +372,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 5,
|
funcId: 6,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -366,7 +406,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 6,
|
funcId: 7,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -396,7 +436,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 7,
|
funcId: 8,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -423,7 +463,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 8,
|
funcId: 9,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -441,6 +481,68 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiDisconnectConstMeta =>
|
TaskConstMeta get kCrateApiDisconnectConstMeta =>
|
||||||
const TaskConstMeta(debugName: "disconnect", argNames: []);
|
const TaskConstMeta(debugName: "disconnect", argNames: []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> crateApiDownloadAvatar({
|
||||||
|
required String avatarHash,
|
||||||
|
required String clientUid,
|
||||||
|
}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_String(avatarHash, serializer);
|
||||||
|
sse_encode_String(clientUid, serializer);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 10,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_opt_list_prim_u_8_strict,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiDownloadAvatarConstMeta,
|
||||||
|
argValues: [avatarHash, clientUid],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiDownloadAvatarConstMeta => const TaskConstMeta(
|
||||||
|
debugName: "download_avatar",
|
||||||
|
argNames: ["avatarHash", "clientUid"],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> crateApiDownloadIcon({required BigInt iconId}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_u_64(iconId, serializer);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 11,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_opt_list_prim_u_8_strict,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiDownloadIconConstMeta,
|
||||||
|
argValues: [iconId],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiDownloadIconConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "download_icon", argNames: ["iconId"]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled}) {
|
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled}) {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
@@ -451,7 +553,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 9,
|
funcId: 12,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -484,7 +586,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 10,
|
funcId: 13,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -510,7 +612,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
SyncTask(
|
SyncTask(
|
||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_String,
|
decodeSuccessData: sse_decode_String,
|
||||||
@@ -526,6 +628,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiExportDiagnosticsConstMeta =>
|
TaskConstMeta get kCrateApiExportDiagnosticsConstMeta =>
|
||||||
const TaskConstMeta(debugName: "export_diagnostics", argNames: []);
|
const TaskConstMeta(debugName: "export_diagnostics", argNames: []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BigInt> crateApiFileCacheSize() {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 15,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_u_64,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiFileCacheSizeConstMeta,
|
||||||
|
argValues: [],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiFileCacheSizeConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "file_cache_size", argNames: []);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig() {
|
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig() {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
@@ -535,7 +664,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 12,
|
funcId: 16,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -565,7 +694,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 13,
|
funcId: 17,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -592,7 +721,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 14,
|
funcId: 18,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -619,7 +748,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 15,
|
funcId: 19,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -643,7 +772,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
SyncTask(
|
SyncTask(
|
||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -666,7 +795,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_bool(shouldResume, serializer);
|
sse_encode_bool(shouldResume, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -692,7 +821,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_String(routeClass, serializer);
|
sse_encode_String(routeClass, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 22)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -718,7 +847,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_bridge_audio_route(route, serializer);
|
sse_encode_bridge_audio_route(route, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -736,6 +865,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
argNames: ["route"],
|
argNames: ["route"],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> crateApiInitCache({required String dir}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_String(dir, serializer);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 24,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_unit,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiInitCacheConstMeta,
|
||||||
|
argValues: [dir],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiInitCacheConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "init_cache", argNames: ["dir"]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> crateApiInitStorage({required String dir}) {
|
Future<void> crateApiInitStorage({required String dir}) {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
@@ -746,7 +903,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 20,
|
funcId: 25,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -776,7 +933,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 21,
|
funcId: 26,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -805,7 +962,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 22,
|
funcId: 27,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -832,7 +989,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 23,
|
funcId: 28,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -859,7 +1016,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 24,
|
funcId: 29,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -883,7 +1040,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
SyncTask(
|
SyncTask(
|
||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_String,
|
decodeSuccessData: sse_decode_String,
|
||||||
@@ -913,7 +1070,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 26,
|
funcId: 31,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -943,7 +1100,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 27,
|
funcId: 32,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -970,7 +1127,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 28,
|
funcId: 33,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -995,7 +1152,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_String(state, serializer);
|
sse_encode_String(state, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 34)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -1028,7 +1185,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 30,
|
funcId: 35,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1055,7 +1212,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_bridge_audio_route(route, serializer);
|
sse_encode_bridge_audio_route(route, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 36)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -1089,7 +1246,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 32,
|
funcId: 37,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1124,7 +1281,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 33,
|
funcId: 38,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1154,7 +1311,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 34,
|
funcId: 39,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1182,7 +1339,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 35,
|
funcId: 40,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1210,7 +1367,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 36,
|
funcId: 41,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1240,7 +1397,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 37,
|
funcId: 42,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1268,7 +1425,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_bridge_network_state(state, serializer);
|
sse_encode_bridge_network_state(state, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 43)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -1294,7 +1451,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 39,
|
funcId: 44,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1322,7 +1479,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 40,
|
funcId: 45,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1350,7 +1507,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 41,
|
funcId: 46,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1378,7 +1535,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 42,
|
funcId: 47,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1410,7 +1567,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 43,
|
funcId: 48,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1440,7 +1597,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 44,
|
funcId: 49,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1468,7 +1625,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 45,
|
funcId: 50,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1496,7 +1653,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 46,
|
funcId: 51,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1523,7 +1680,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 47,
|
funcId: 52,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1551,7 +1708,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 48,
|
funcId: 53,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1583,7 +1740,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 49,
|
funcId: 54,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1612,7 +1769,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 50,
|
funcId: 55,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1683,6 +1840,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return dco_decode_bridge_message_target(raw);
|
return dco_decode_bridge_message_target(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return dco_decode_bridge_poke_strength(raw);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
||||||
dynamic raw,
|
dynamic raw,
|
||||||
@@ -2007,6 +2170,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
senderName: dco_decode_String(raw[2]),
|
senderName: dco_decode_String(raw[2]),
|
||||||
message: dco_decode_String(raw[3]),
|
message: dco_decode_String(raw[3]),
|
||||||
target: dco_decode_box_autoadd_bridge_message_target(raw[4]),
|
target: dco_decode_box_autoadd_bridge_message_target(raw[4]),
|
||||||
|
pokeStrength: dco_decode_opt_box_autoadd_bridge_poke_strength(raw[5]),
|
||||||
);
|
);
|
||||||
case 11:
|
case 11:
|
||||||
return BridgeEvent_ServerActivity(message: dco_decode_String(raw[1]));
|
return BridgeEvent_ServerActivity(message: dco_decode_String(raw[1]));
|
||||||
@@ -2098,6 +2262,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return BridgeNetworkState.values[raw as int];
|
return BridgeNetworkState.values[raw as int];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return BridgePokeStrength.values[raw as int];
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw) {
|
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
@@ -2234,6 +2404,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return raw == null ? null : dco_decode_String(raw);
|
return raw == null ? null : dco_decode_String(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
dynamic raw,
|
||||||
|
) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return raw == null
|
||||||
|
? null
|
||||||
|
: dco_decode_box_autoadd_bridge_poke_strength(raw);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode?
|
BridgeVoiceJoinErrorCode?
|
||||||
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw) {
|
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw) {
|
||||||
@@ -2267,6 +2447,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return raw == null ? null : dco_decode_box_autoadd_u_64(raw);
|
return raw == null ? null : dco_decode_box_autoadd_u_64(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return raw == null ? null : dco_decode_list_prim_u_8_strict(raw);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
PermissionStateKind dco_decode_permission_state_kind(dynamic raw) {
|
PermissionStateKind dco_decode_permission_state_kind(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
@@ -2358,6 +2544,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return (sse_decode_bridge_message_target(deserializer));
|
return (sse_decode_bridge_message_target(deserializer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
return (sse_decode_bridge_poke_strength(deserializer));
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -2809,11 +3003,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
var var_target = sse_decode_box_autoadd_bridge_message_target(
|
var var_target = sse_decode_box_autoadd_bridge_message_target(
|
||||||
deserializer,
|
deserializer,
|
||||||
);
|
);
|
||||||
|
var var_pokeStrength = sse_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
deserializer,
|
||||||
|
);
|
||||||
return BridgeEvent_ChatMessage(
|
return BridgeEvent_ChatMessage(
|
||||||
senderId: var_senderId,
|
senderId: var_senderId,
|
||||||
senderName: var_senderName,
|
senderName: var_senderName,
|
||||||
message: var_message,
|
message: var_message,
|
||||||
target: var_target,
|
target: var_target,
|
||||||
|
pokeStrength: var_pokeStrength,
|
||||||
);
|
);
|
||||||
case 11:
|
case 11:
|
||||||
var var_message = sse_decode_String(deserializer);
|
var var_message = sse_decode_String(deserializer);
|
||||||
@@ -2941,6 +3139,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return BridgeNetworkState.values[inner];
|
return BridgeNetworkState.values[inner];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength sse_decode_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
var inner = sse_decode_i_32(deserializer);
|
||||||
|
return BridgePokeStrength.values[inner];
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer) {
|
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer) {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
@@ -3132,6 +3339,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
|
||||||
|
if (sse_decode_bool(deserializer)) {
|
||||||
|
return (sse_decode_box_autoadd_bridge_poke_strength(deserializer));
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode?
|
BridgeVoiceJoinErrorCode?
|
||||||
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||||
@@ -3192,6 +3412,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
|
||||||
|
if (sse_decode_bool(deserializer)) {
|
||||||
|
return (sse_decode_list_prim_u_8_strict(deserializer));
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
PermissionStateKind sse_decode_permission_state_kind(
|
PermissionStateKind sse_decode_permission_state_kind(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -3306,6 +3537,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
sse_encode_bridge_message_target(self, serializer);
|
sse_encode_bridge_message_target(self, serializer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_box_autoadd_bridge_poke_strength(
|
||||||
|
BridgePokeStrength self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
sse_encode_bridge_poke_strength(self, serializer);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode self,
|
BridgeVoiceJoinErrorCode self,
|
||||||
@@ -3644,12 +3884,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
senderName: final senderName,
|
senderName: final senderName,
|
||||||
message: final message,
|
message: final message,
|
||||||
target: final target,
|
target: final target,
|
||||||
|
pokeStrength: final pokeStrength,
|
||||||
):
|
):
|
||||||
sse_encode_i_32(10, serializer);
|
sse_encode_i_32(10, serializer);
|
||||||
sse_encode_u_64(senderId, serializer);
|
sse_encode_u_64(senderId, serializer);
|
||||||
sse_encode_String(senderName, serializer);
|
sse_encode_String(senderName, serializer);
|
||||||
sse_encode_String(message, serializer);
|
sse_encode_String(message, serializer);
|
||||||
sse_encode_box_autoadd_bridge_message_target(target, serializer);
|
sse_encode_box_autoadd_bridge_message_target(target, serializer);
|
||||||
|
sse_encode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
pokeStrength,
|
||||||
|
serializer,
|
||||||
|
);
|
||||||
case BridgeEvent_ServerActivity(message: final message):
|
case BridgeEvent_ServerActivity(message: final message):
|
||||||
sse_encode_i_32(11, serializer);
|
sse_encode_i_32(11, serializer);
|
||||||
sse_encode_String(message, serializer);
|
sse_encode_String(message, serializer);
|
||||||
@@ -3771,6 +4016,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
sse_encode_i_32(self.index, serializer);
|
sse_encode_i_32(self.index, serializer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_poke_strength(
|
||||||
|
BridgePokeStrength self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
sse_encode_i_32(self.index, serializer);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_ptt_binding(
|
void sse_encode_bridge_ptt_binding(
|
||||||
BridgePttBinding self,
|
BridgePttBinding self,
|
||||||
@@ -3947,6 +4201,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
BridgePokeStrength? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
|
||||||
|
sse_encode_bool(self != null, serializer);
|
||||||
|
if (self != null) {
|
||||||
|
sse_encode_box_autoadd_bridge_poke_strength(self, serializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode? self,
|
BridgeVoiceJoinErrorCode? self,
|
||||||
@@ -4003,6 +4270,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_list_prim_u_8_strict(
|
||||||
|
Uint8List? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
|
||||||
|
sse_encode_bool(self != null, serializer);
|
||||||
|
if (self != null) {
|
||||||
|
sse_encode_list_prim_u_8_strict(self, serializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_permission_state_kind(
|
void sse_encode_permission_state_kind(
|
||||||
PermissionStateKind self,
|
PermissionStateKind self,
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
|
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
||||||
dynamic raw,
|
dynamic raw,
|
||||||
@@ -120,6 +123,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
|
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
|
||||||
|
|
||||||
@@ -174,6 +180,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
String? dco_decode_opt_String(dynamic raw);
|
String? dco_decode_opt_String(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
dynamic raw,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode?
|
BridgeVoiceJoinErrorCode?
|
||||||
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
|
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
|
||||||
@@ -190,6 +201,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
|
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
|
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
|
||||||
|
|
||||||
@@ -240,6 +254,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -328,6 +347,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength sse_decode_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
|
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
|
||||||
|
|
||||||
@@ -400,6 +424,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
String? sse_decode_opt_String(SseDeserializer deserializer);
|
String? sse_decode_opt_String(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode?
|
BridgeVoiceJoinErrorCode?
|
||||||
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||||
@@ -418,6 +447,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
|
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
PermissionStateKind sse_decode_permission_state_kind(
|
PermissionStateKind sse_decode_permission_state_kind(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -477,6 +509,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_box_autoadd_bridge_poke_strength(
|
||||||
|
BridgePokeStrength self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode self,
|
BridgeVoiceJoinErrorCode self,
|
||||||
@@ -588,6 +626,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_poke_strength(
|
||||||
|
BridgePokeStrength self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_ptt_binding(
|
void sse_encode_bridge_ptt_binding(
|
||||||
BridgePttBinding self,
|
BridgePttBinding self,
|
||||||
@@ -681,6 +725,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
BridgePokeStrength? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode? self,
|
BridgeVoiceJoinErrorCode? self,
|
||||||
@@ -702,6 +752,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
|
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_list_prim_u_8_strict(
|
||||||
|
Uint8List? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_permission_state_kind(
|
void sse_encode_permission_state_kind(
|
||||||
PermissionStateKind self,
|
PermissionStateKind self,
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
|
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
||||||
dynamic raw,
|
dynamic raw,
|
||||||
@@ -122,6 +125,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
|
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
|
||||||
|
|
||||||
@@ -176,6 +182,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
String? dco_decode_opt_String(dynamic raw);
|
String? dco_decode_opt_String(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
dynamic raw,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode?
|
BridgeVoiceJoinErrorCode?
|
||||||
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
|
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
|
||||||
@@ -192,6 +203,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
|
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
|
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
|
||||||
|
|
||||||
@@ -242,6 +256,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -330,6 +349,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength sse_decode_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
|
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
|
||||||
|
|
||||||
@@ -402,6 +426,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
String? sse_decode_opt_String(SseDeserializer deserializer);
|
String? sse_decode_opt_String(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode?
|
BridgeVoiceJoinErrorCode?
|
||||||
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||||
@@ -420,6 +449,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
|
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
PermissionStateKind sse_decode_permission_state_kind(
|
PermissionStateKind sse_decode_permission_state_kind(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -479,6 +511,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_box_autoadd_bridge_poke_strength(
|
||||||
|
BridgePokeStrength self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode self,
|
BridgeVoiceJoinErrorCode self,
|
||||||
@@ -590,6 +628,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_poke_strength(
|
||||||
|
BridgePokeStrength self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_ptt_binding(
|
void sse_encode_bridge_ptt_binding(
|
||||||
BridgePttBinding self,
|
BridgePttBinding self,
|
||||||
@@ -683,6 +727,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
BridgePokeStrength? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode? self,
|
BridgeVoiceJoinErrorCode? self,
|
||||||
@@ -704,6 +754,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
|
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_list_prim_u_8_strict(
|
||||||
|
Uint8List? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_permission_state_kind(
|
void sse_encode_permission_state_kind(
|
||||||
PermissionStateKind self,
|
PermissionStateKind self,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class AudioDeviceListTile extends StatefulWidget {
|
|||||||
AudioDeviceListLoader? loadDevices,
|
AudioDeviceListLoader? loadDevices,
|
||||||
AudioDeviceSetter? setInputDevice,
|
AudioDeviceSetter? setInputDevice,
|
||||||
AudioDeviceSetter? setOutputDevice,
|
AudioDeviceSetter? setOutputDevice,
|
||||||
|
this.onDeviceChanged,
|
||||||
}) : loadDevices = loadDevices ?? rust.listAudioDevices,
|
}) : loadDevices = loadDevices ?? rust.listAudioDevices,
|
||||||
setInputDevice = setInputDevice ?? rust.setInputDevice,
|
setInputDevice = setInputDevice ?? rust.setInputDevice,
|
||||||
setOutputDevice = setOutputDevice ?? rust.setOutputDevice;
|
setOutputDevice = setOutputDevice ?? rust.setOutputDevice;
|
||||||
@@ -46,6 +47,10 @@ class AudioDeviceListTile extends StatefulWidget {
|
|||||||
/// Selects an output device.
|
/// Selects an output device.
|
||||||
final AudioDeviceSetter setOutputDevice;
|
final AudioDeviceSetter setOutputDevice;
|
||||||
|
|
||||||
|
/// Called after a device selection succeeds. Receives the device id
|
||||||
|
/// (null for system default).
|
||||||
|
final ValueChanged<String?>? onDeviceChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AudioDeviceListTile> createState() => _AudioDeviceListTileState();
|
State<AudioDeviceListTile> createState() => _AudioDeviceListTileState();
|
||||||
}
|
}
|
||||||
@@ -88,6 +93,7 @@ class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_selectedDeviceId = deviceId;
|
_selectedDeviceId = deviceId;
|
||||||
});
|
});
|
||||||
|
widget.onDeviceChanged?.call(deviceId);
|
||||||
|
|
||||||
final selectedName = _selectedDevice?.name ?? 'System default';
|
final selectedName = _selectedDevice?.name ?? 'System default';
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -93,78 +93,51 @@ class AudioProcessingConfigState {
|
|||||||
isLinux: linux,
|
isLinux: linux,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
rust.BridgeAudioBackend processingBackend;
|
||||||
|
rust.BridgeEffectOwner aec;
|
||||||
|
rust.BridgeEffectOwner ns;
|
||||||
|
rust.BridgeEffectOwner agc;
|
||||||
|
|
||||||
if (android) {
|
if (android) {
|
||||||
final owner = preferHardware
|
final owner = preferHardware
|
||||||
? rust.BridgeEffectOwner.platform
|
? rust.BridgeEffectOwner.platform
|
||||||
: rust.BridgeEffectOwner.webrtcApm;
|
: rust.BridgeEffectOwner.webrtcApm;
|
||||||
return rust.BridgeAudioProcessingConfig(
|
processingBackend = preferHardware
|
||||||
route: base.route,
|
? rust.BridgeAudioBackend.platformVoiceProcessing
|
||||||
iosMode: normalizedIosProcessingMode(iosMode),
|
: rust.BridgeAudioBackend.webrtcApm;
|
||||||
processingBackend: preferHardware
|
aec = aecEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||||
? rust.BridgeAudioBackend.platformVoiceProcessing
|
ns = nsEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||||
: rust.BridgeAudioBackend.webrtcApm,
|
agc = agcEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||||
vadBackend: vad,
|
} else if (appleVoiceProcessing) {
|
||||||
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
|
processingBackend = rust.BridgeAudioBackend.platformVoiceProcessing;
|
||||||
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
|
aec = rust.BridgeEffectOwner.platform;
|
||||||
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
|
ns = rust.BridgeEffectOwner.platform;
|
||||||
hpfEnabled: hpfEnabled,
|
agc = rust.BridgeEffectOwner.platform;
|
||||||
limiterEnabled: limiterEnabled,
|
} else if (desktopWebrtcApm) {
|
||||||
vadHangoverMs: base.vadHangoverMs,
|
|
||||||
vadPreRollMs: base.vadPreRollMs,
|
|
||||||
vadMinTxMs: base.vadMinTxMs,
|
|
||||||
debugWavDumpEnabled: debugWavDump,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appleVoiceProcessing) {
|
|
||||||
return rust.BridgeAudioProcessingConfig(
|
|
||||||
route: base.route,
|
|
||||||
iosMode: normalizedIosProcessingMode(iosMode),
|
|
||||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
|
||||||
vadBackend: vad,
|
|
||||||
aec: rust.BridgeEffectOwner.platform,
|
|
||||||
ns: rust.BridgeEffectOwner.platform,
|
|
||||||
agc: rust.BridgeEffectOwner.platform,
|
|
||||||
hpfEnabled: hpfEnabled,
|
|
||||||
limiterEnabled: limiterEnabled,
|
|
||||||
vadHangoverMs: base.vadHangoverMs,
|
|
||||||
vadPreRollMs: base.vadPreRollMs,
|
|
||||||
vadMinTxMs: base.vadMinTxMs,
|
|
||||||
debugWavDumpEnabled: debugWavDump,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desktopWebrtcApm) {
|
|
||||||
final owner = rust.BridgeEffectOwner.webrtcApm;
|
final owner = rust.BridgeEffectOwner.webrtcApm;
|
||||||
return rust.BridgeAudioProcessingConfig(
|
processingBackend = rust.BridgeAudioBackend.webrtcApm;
|
||||||
route: base.route,
|
aec = aecEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||||
iosMode: normalizedIosProcessingMode(iosMode),
|
ns = nsEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||||
processingBackend: rust.BridgeAudioBackend.webrtcApm,
|
agc = agcEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||||
vadBackend: vad,
|
} else {
|
||||||
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
|
processingBackend = rust.BridgeAudioBackend.platformVoiceProcessing;
|
||||||
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
|
aec = rust.BridgeEffectOwner.platform;
|
||||||
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
|
ns = nsEnabled
|
||||||
hpfEnabled: hpfEnabled,
|
? rust.BridgeEffectOwner.platform
|
||||||
limiterEnabled: limiterEnabled,
|
: rust.BridgeEffectOwner.off;
|
||||||
vadHangoverMs: base.vadHangoverMs,
|
agc = agcEnabled
|
||||||
vadPreRollMs: base.vadPreRollMs,
|
? rust.BridgeEffectOwner.platform
|
||||||
vadMinTxMs: base.vadMinTxMs,
|
: rust.BridgeEffectOwner.off;
|
||||||
debugWavDumpEnabled: debugWavDump,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rust.BridgeAudioProcessingConfig(
|
return rust.BridgeAudioProcessingConfig(
|
||||||
route: base.route,
|
route: base.route,
|
||||||
iosMode: normalizedIosProcessingMode(iosMode),
|
iosMode: normalizedIosProcessingMode(iosMode),
|
||||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
processingBackend: processingBackend,
|
||||||
vadBackend: vad,
|
vadBackend: vad,
|
||||||
aec: rust.BridgeEffectOwner.platform,
|
aec: aec,
|
||||||
ns: nsEnabled
|
ns: ns,
|
||||||
? rust.BridgeEffectOwner.platform
|
agc: agc,
|
||||||
: rust.BridgeEffectOwner.off,
|
|
||||||
agc: agcEnabled
|
|
||||||
? rust.BridgeEffectOwner.platform
|
|
||||||
: rust.BridgeEffectOwner.off,
|
|
||||||
hpfEnabled: hpfEnabled,
|
hpfEnabled: hpfEnabled,
|
||||||
limiterEnabled: limiterEnabled,
|
limiterEnabled: limiterEnabled,
|
||||||
vadHangoverMs: base.vadHangoverMs,
|
vadHangoverMs: base.vadHangoverMs,
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ const double _chatSidebarTileExtent = 92;
|
|||||||
const double _chatSidebarCompactTileExtent = 76;
|
const double _chatSidebarCompactTileExtent = 76;
|
||||||
const double _chatSidebarCompactHeight = 84;
|
const double _chatSidebarCompactHeight = 84;
|
||||||
|
|
||||||
|
typedef ChatMessageSender =
|
||||||
|
Future<void> Function({
|
||||||
|
required String message,
|
||||||
|
required rust.BridgeMessageTarget target,
|
||||||
|
});
|
||||||
|
|
||||||
/// One chat/activity message shown in the chat hub.
|
/// One chat/activity message shown in the chat hub.
|
||||||
class ChatEntry {
|
class ChatEntry {
|
||||||
/// Construct a chat entry.
|
/// Construct a chat entry.
|
||||||
@@ -497,7 +503,7 @@ String chatInputPlaceholder(
|
|||||||
case rust.BridgeMessageTarget_Client():
|
case rust.BridgeMessageTarget_Client():
|
||||||
return 'Message $clientName...';
|
return 'Message $clientName...';
|
||||||
case rust.BridgeMessageTarget_Poke():
|
case rust.BridgeMessageTarget_Poke():
|
||||||
return 'Poke message...';
|
return 'Poke message optional...';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,6 +519,16 @@ bool canSendToChatTarget(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool canSendChatMessage(
|
||||||
|
rust.BridgeMessageTarget target,
|
||||||
|
BigInt? currentChannelId,
|
||||||
|
String text,
|
||||||
|
) {
|
||||||
|
if (!canSendToChatTarget(target, currentChannelId)) return false;
|
||||||
|
if (target is rust.BridgeMessageTarget_Poke) return true;
|
||||||
|
return text.trim().isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
String? chatSendBlockedReason(
|
String? chatSendBlockedReason(
|
||||||
rust.BridgeMessageTarget target,
|
rust.BridgeMessageTarget target,
|
||||||
BigInt? currentChannelId,
|
BigInt? currentChannelId,
|
||||||
@@ -1066,6 +1082,7 @@ class ChatDetailView extends StatefulWidget {
|
|||||||
this.messageMaxWidth,
|
this.messageMaxWidth,
|
||||||
this.restoredDraft,
|
this.restoredDraft,
|
||||||
this.onDraftChanged,
|
this.onDraftChanged,
|
||||||
|
this.sendChatMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Chat target displayed by this detail view.
|
/// Chat target displayed by this detail view.
|
||||||
@@ -1101,6 +1118,9 @@ class ChatDetailView extends StatefulWidget {
|
|||||||
/// Called with the current draft text whenever the target changes or the widget is about to be replaced.
|
/// Called with the current draft text whenever the target changes or the widget is about to be replaced.
|
||||||
final ValueChanged<String>? onDraftChanged;
|
final ValueChanged<String>? onDraftChanged;
|
||||||
|
|
||||||
|
/// Sends a chat message. Defaults to the Rust bridge send path.
|
||||||
|
final ChatMessageSender? sendChatMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatDetailView> createState() => _ChatDetailViewState();
|
State<ChatDetailView> createState() => _ChatDetailViewState();
|
||||||
}
|
}
|
||||||
@@ -1168,9 +1188,12 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
|||||||
|
|
||||||
void _send() {
|
void _send() {
|
||||||
final text = _textCtl.text.trim();
|
final text = _textCtl.text.trim();
|
||||||
if (text.isEmpty || !_canSend) return;
|
if (!canSendChatMessage(widget.target, widget.currentChannelId, text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_textCtl.clear();
|
_textCtl.clear();
|
||||||
unawaited(rust.sendChatMessage(message: text, target: widget.target));
|
final sendChatMessage = widget.sendChatMessage ?? rust.sendChatMessage;
|
||||||
|
unawaited(sendChatMessage(message: text, target: widget.target));
|
||||||
final ownId = widget.snapshot.ownClientId;
|
final ownId = widget.snapshot.ownClientId;
|
||||||
setState(() {
|
setState(() {
|
||||||
widget.messages.add(
|
widget.messages.add(
|
||||||
@@ -1223,6 +1246,9 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
|||||||
channelName: widget.channelName,
|
channelName: widget.channelName,
|
||||||
clientName: widget.clientName,
|
clientName: widget.clientName,
|
||||||
);
|
);
|
||||||
|
final sendTooltip = widget.target is rust.BridgeMessageTarget_Poke
|
||||||
|
? 'Poke'
|
||||||
|
: 'Send';
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -1332,7 +1358,7 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
|||||||
IconButton.filled(
|
IconButton.filled(
|
||||||
icon: const Icon(Icons.send),
|
icon: const Icon(Icons.send),
|
||||||
onPressed: _send,
|
onPressed: _send,
|
||||||
tooltip: 'Send',
|
tooltip: sendTooltip,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../l10n/generated/app_localizations.dart';
|
||||||
|
import '../services/poke_notification_service.dart';
|
||||||
|
import '../services/poke_preferences_service.dart';
|
||||||
|
import 'voice_settings_controls.dart';
|
||||||
|
|
||||||
|
class PokeNotificationSettingsDialog extends StatelessWidget {
|
||||||
|
const PokeNotificationSettingsDialog({
|
||||||
|
super.key,
|
||||||
|
required this.preferences,
|
||||||
|
required this.notificationService,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PokePreferencesService preferences;
|
||||||
|
final PokeNotificationService notificationService;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppL10n.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(l10n.pokeSettingsTitle),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: preferences.pokesEnabled,
|
||||||
|
builder: (context, enabled, _) => SwitchListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(l10n.pokeSettingsEnableLabel),
|
||||||
|
subtitle: Text(l10n.pokeSettingsEnableDescription),
|
||||||
|
value: enabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await preferences.setPokesEnabled(value);
|
||||||
|
// Request notification permission when enabling pokes
|
||||||
|
// so the OS prompt appears immediately rather than on
|
||||||
|
// the first poke event.
|
||||||
|
if (value) {
|
||||||
|
await notificationService.requestPermission();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
VoiceSectionHeader(l10n.pokeSettingsMutedSendersHeader),
|
||||||
|
ValueListenableBuilder<Set<BigInt>>(
|
||||||
|
valueListenable: preferences.mutedSenders,
|
||||||
|
builder: (context, mutedSenders, _) {
|
||||||
|
final senders = mutedSenders.toList()..sort();
|
||||||
|
if (senders.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
l10n.pokeSettingsMutedSendersEmpty,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (final senderId in senders)
|
||||||
|
ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.notifications_off_outlined),
|
||||||
|
title: Text(
|
||||||
|
l10n.pokeSettingsMutedSenderLabel(
|
||||||
|
senderId.toString(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () => preferences.unmuteSender(senderId),
|
||||||
|
child: Text(l10n.pokeSettingsUnmuteSenderAction),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(l10n.closeAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
import '../services/channel_spacer.dart';
|
import '../services/channel_spacer.dart';
|
||||||
@@ -10,7 +11,14 @@ import '../src/rust/api.dart' as rust;
|
|||||||
import 'bbcode_text.dart';
|
import 'bbcode_text.dart';
|
||||||
import 'talk_power_warning.dart';
|
import 'talk_power_warning.dart';
|
||||||
|
|
||||||
/// Connected-server snapshot with welcome text, channels, and clients.
|
/// Connected-server snapshot displaying welcome text, channel tree, and clients.
|
||||||
|
///
|
||||||
|
/// Renders the full channel hierarchy from a [BridgeSnapshot] with expandable
|
||||||
|
/// channel nodes, client voice-status indicators, unread-message badges, and
|
||||||
|
/// context menus for client actions (info, chat, poke, volume).
|
||||||
|
///
|
||||||
|
/// Channel join is triggered by tapping an unlocked channel row; password-
|
||||||
|
/// protected channels invoke [onJoinChannelWithPassword] instead.
|
||||||
class SnapshotView extends StatefulWidget {
|
class SnapshotView extends StatefulWidget {
|
||||||
/// Construct a snapshot view.
|
/// Construct a snapshot view.
|
||||||
const SnapshotView({
|
const SnapshotView({
|
||||||
@@ -571,11 +579,40 @@ class _ClientVolumePreference {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ClientVolumePreferences extends ChangeNotifier {
|
class _ClientVolumePreferences extends ChangeNotifier {
|
||||||
_ClientVolumePreferences._();
|
_ClientVolumePreferences._() {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
static final instance = _ClientVolumePreferences._();
|
static final instance = _ClientVolumePreferences._();
|
||||||
|
|
||||||
|
static const _prefsKey = 'client_volume_prefs';
|
||||||
|
|
||||||
final Map<BigInt, _ClientVolumePreference> _byClientId = {};
|
final Map<BigInt, _ClientVolumePreference> _byClientId = {};
|
||||||
|
bool _loaded = false;
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
if (_loaded) return;
|
||||||
|
_loaded = true;
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getStringList(_prefsKey) ?? const [];
|
||||||
|
for (final entry in raw) {
|
||||||
|
final parts = entry.split(':');
|
||||||
|
if (parts.length == 3) {
|
||||||
|
final id = BigInt.tryParse(parts[0]);
|
||||||
|
final volume = double.tryParse(parts[1]);
|
||||||
|
final muted = parts[2] == '1';
|
||||||
|
if (id != null && volume != null) {
|
||||||
|
_byClientId[id] = _ClientVolumePreference(
|
||||||
|
volume: volume,
|
||||||
|
muted: muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
_ClientVolumePreference preferenceFor(BigInt clientId) {
|
_ClientVolumePreference preferenceFor(BigInt clientId) {
|
||||||
return _byClientId[clientId] ?? const _ClientVolumePreference();
|
return _byClientId[clientId] ?? const _ClientVolumePreference();
|
||||||
@@ -588,6 +625,17 @@ class _ClientVolumePreferences extends ChangeNotifier {
|
|||||||
_byClientId.remove(clientId);
|
_byClientId.remove(clientId);
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
_save();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = _byClientId.entries
|
||||||
|
.map((e) => '${e.key}:${e.value.volume}:${e.value.muted ? 1 : 0}')
|
||||||
|
.toList();
|
||||||
|
await prefs.setStringList(_prefsKey, raw);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,7 +1156,7 @@ class _ClientVolumeSheetState extends State<_ClientVolumeSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Context menu for channel tiles. Shows a "Chat" option on right-click or
|
/// Context menu for channel tiles offering "Chat" on right-click or
|
||||||
/// long-press. Primary tap passes through to the child for voice join.
|
/// long-press. Primary tap passes through to the child for voice join.
|
||||||
class _ChannelContextMenu extends StatelessWidget {
|
class _ChannelContextMenu extends StatelessWidget {
|
||||||
const _ChannelContextMenu({
|
const _ChannelContextMenu({
|
||||||
|
|||||||
@@ -26,21 +26,6 @@ import 'voice_settings_controls.dart';
|
|||||||
import 'voice_status_summary.dart';
|
import 'voice_status_summary.dart';
|
||||||
import '../src/rust/api.dart' as rust;
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
bool get _isIos {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isIOS;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isMacOS {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isMacOS;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isDesktopSileroVadHost {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isWindows || Platform.isLinux;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Two-line status chip that summarises the current voice state.
|
/// Two-line status chip that summarises the current voice state.
|
||||||
/// Tap to open the voice details modal.
|
/// Tap to open the voice details modal.
|
||||||
class VoiceStatusChip extends StatelessWidget {
|
class VoiceStatusChip extends StatelessWidget {
|
||||||
@@ -318,6 +303,15 @@ class _VoicePttButtonState extends State<VoicePttButton> {
|
|||||||
playVoicePttHaptic(held);
|
playVoicePttHaptic(held);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (_pressed) {
|
||||||
|
_pressed = false;
|
||||||
|
widget.onHeldChanged(false);
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -629,12 +623,19 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
selected: _mode == rust.BridgeTransmitMode.continuous,
|
selected: _mode == rust.BridgeTransmitMode.continuous,
|
||||||
onTap: () => _setMode(rust.BridgeTransmitMode.continuous),
|
onTap: () => _setMode(rust.BridgeTransmitMode.continuous),
|
||||||
),
|
),
|
||||||
_ModeRow(
|
// Voice-activity transmit is only honoured by the engine on
|
||||||
label: l10n.voiceModeVoiceActivity,
|
// hosts that ship a Chanora-owned VAD pipeline (DEC-030:
|
||||||
icon: Icons.graphic_eq,
|
// Windows + Linux desktop and Android). iOS / macOS rely
|
||||||
selected: _mode == rust.BridgeTransmitMode.voiceActivity,
|
// on Apple VoiceProcessingIO and have no VAD bridge, so
|
||||||
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
// hiding the row prevents the UI from advertising a
|
||||||
),
|
// transmit mode the engine cannot honour.
|
||||||
|
if (voiceActivityTransmitAvailable)
|
||||||
|
_ModeRow(
|
||||||
|
label: l10n.voiceModeVoiceActivity,
|
||||||
|
icon: Icons.graphic_eq,
|
||||||
|
selected: _mode == rust.BridgeTransmitMode.voiceActivity,
|
||||||
|
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
||||||
|
),
|
||||||
|
|
||||||
// 3) Release-tail slider (PTT only).
|
// 3) Release-tail slider (PTT only).
|
||||||
if (isPtt) ...[
|
if (isPtt) ...[
|
||||||
@@ -721,130 +722,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
// Android HW/SW selector.
|
AudioProcessingPanel(
|
||||||
if (Platform.isAndroid) ...[
|
config: _audioProcessing,
|
||||||
const VoiceSubHeader('Processing backend'),
|
dense: true,
|
||||||
SegmentedButton<bool>(
|
update: (mutation) {
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
setState(mutation);
|
||||||
segments: androidProcessingSegments,
|
|
||||||
selected: {_audioProcessing.preferHardware},
|
|
||||||
onSelectionChanged: (s) {
|
|
||||||
setState(() => _audioProcessing.preferHardware = s.first);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_audioProcessing.preferHardware
|
|
||||||
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
|
||||||
: 'Software mode applies the full WebRTC APM stage set.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
if (_isIos) ...[
|
|
||||||
Text(
|
|
||||||
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
|
||||||
'hidden here; only settings that still affect the shipping '
|
|
||||||
'iOS path are shown.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
],
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsNsControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Noise suppression',
|
|
||||||
subtitle: 'Wiener filter',
|
|
||||||
value: _audioProcessing.nsEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.nsEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsAecControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Echo cancellation',
|
|
||||||
subtitle: Platform.isAndroid
|
|
||||||
? (_audioProcessing.preferHardware
|
|
||||||
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
|
||||||
: 'WebRTC AEC3 · adaptive filter')
|
|
||||||
: (_isMacOS
|
|
||||||
? 'Managed by platform VPIO'
|
|
||||||
: 'WebRTC AEC3 · adaptive filter'),
|
|
||||||
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
|
||||||
onChanged: _isMacOS
|
|
||||||
? null
|
|
||||||
: (v) {
|
|
||||||
setState(() => _audioProcessing.aecEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsAgcControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Auto gain control',
|
|
||||||
subtitle: 'AGC2 · -18 dBFS target',
|
|
||||||
value: _audioProcessing.agcEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.agcEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'High-pass filter',
|
|
||||||
subtitle: '80 Hz · DC removal',
|
|
||||||
value: _audioProcessing.hpfEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.hpfEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsLimiterControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Peak limiter',
|
|
||||||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
|
||||||
value: _audioProcessing.limiterEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.limiterEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// VAD backend.
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Voice activity detection (VAD)',
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
SegmentedButton<rust.BridgeVadBackend>(
|
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
|
||||||
segments: _isDesktopSileroVadHost
|
|
||||||
? desktopVadBackendSegments
|
|
||||||
: vadBackendSegments,
|
|
||||||
selected: {_audioProcessing.vadBackend},
|
|
||||||
onSelectionChanged: (s) {
|
|
||||||
setState(() => _audioProcessing.vadBackend = s.first);
|
|
||||||
_notifyAudioConfig();
|
_notifyAudioConfig();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,9 +2,17 @@ import 'dart:io' show Platform;
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
|
||||||
/// True when the host is a touch-only mobile platform without a
|
// TODO(refactor): Scattered Platform.isX checks exist across ~6 Dart files.
|
||||||
/// hardware keyboard the user would bind a PTT key on.
|
// Centralize all platform checks here and update call sites to use these
|
||||||
bool get isTouchOnlyPttHost {
|
// getters instead of raw Platform.isAndroid/isIOS/etc.
|
||||||
if (kIsWeb) return false;
|
bool get _notWeb => !kIsWeb;
|
||||||
return Platform.isIOS || Platform.isAndroid;
|
|
||||||
}
|
bool get isTouchOnlyPttHost => _notWeb && (Platform.isIOS || Platform.isAndroid);
|
||||||
|
|
||||||
|
bool get isAndroidHost => _notWeb && Platform.isAndroid;
|
||||||
|
|
||||||
|
bool get isIosHost => _notWeb && Platform.isIOS;
|
||||||
|
|
||||||
|
bool get isMacOsHost => _notWeb && Platform.isMacOS;
|
||||||
|
|
||||||
|
bool get isDesktopSileroVadHost => _notWeb && (Platform.isWindows || Platform.isLinux);
|
||||||
|
|||||||
@@ -7,9 +7,6 @@
|
|||||||
// - VAD backend
|
// - VAD backend
|
||||||
// - platform audio-processing mode selection where available
|
// - platform audio-processing mode selection where available
|
||||||
|
|
||||||
import 'dart:io' show Platform;
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
@@ -22,27 +19,8 @@ import 'voice_platform.dart';
|
|||||||
import 'voice_settings_controls.dart';
|
import 'voice_settings_controls.dart';
|
||||||
import '../src/rust/api.dart' as rust;
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
bool get _isAndroid {
|
/// Result returned by [VoiceSettingsDialog] when the user saves or
|
||||||
if (kIsWeb) return false;
|
/// requests a key bind.
|
||||||
return Platform.isAndroid;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isIos {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isIOS;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isMacOS {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isMacOS;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isDesktopSileroVadHost {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isWindows || Platform.isLinux;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result returned by [VoiceSettingsDialog].
|
|
||||||
class VoiceSettingsResult {
|
class VoiceSettingsResult {
|
||||||
const VoiceSettingsResult({
|
const VoiceSettingsResult({
|
||||||
required this.mode,
|
required this.mode,
|
||||||
@@ -57,7 +35,15 @@ class VoiceSettingsResult {
|
|||||||
final rust.BridgeAudioProcessingConfig audioConfig;
|
final rust.BridgeAudioProcessingConfig audioConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Voice + audio processing settings dialog.
|
/// Dialog for configuring voice transmission and audio processing settings.
|
||||||
|
///
|
||||||
|
/// Surfaces transmit mode selection (continuous, PTT, voice-activity),
|
||||||
|
/// PTT release-tail slider, key-bind request, and a full audio processing
|
||||||
|
/// panel covering noise suppression, echo cancellation, AGC, HPF, and VAD
|
||||||
|
/// backend selection.
|
||||||
|
///
|
||||||
|
/// On mobile hosts, the voice-activity segment is hidden when no
|
||||||
|
/// Chanora-owned VAD pipeline is available (iOS, macOS, web).
|
||||||
class VoiceSettingsDialog extends StatefulWidget {
|
class VoiceSettingsDialog extends StatefulWidget {
|
||||||
const VoiceSettingsDialog({
|
const VoiceSettingsDialog({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -70,6 +56,8 @@ class VoiceSettingsDialog extends StatefulWidget {
|
|||||||
this.talkPower,
|
this.talkPower,
|
||||||
this.neededTalkPower,
|
this.neededTalkPower,
|
||||||
this.talkPowerGranted,
|
this.talkPowerGranted,
|
||||||
|
this.onInputDeviceChanged,
|
||||||
|
this.onOutputDeviceChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final rust.BridgeTransmitMode initialMode;
|
final rust.BridgeTransmitMode initialMode;
|
||||||
@@ -81,6 +69,8 @@ class VoiceSettingsDialog extends StatefulWidget {
|
|||||||
final int? talkPower;
|
final int? talkPower;
|
||||||
final int? neededTalkPower;
|
final int? neededTalkPower;
|
||||||
final bool? talkPowerGranted;
|
final bool? talkPowerGranted;
|
||||||
|
final ValueChanged<String?>? onInputDeviceChanged;
|
||||||
|
final ValueChanged<String?>? onOutputDeviceChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
||||||
@@ -106,7 +96,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||||
return _audioProcessing.buildConfig(
|
return _audioProcessing.buildConfig(
|
||||||
base: widget.initialAudioConfig,
|
base: widget.initialAudioConfig,
|
||||||
isAndroid: _isAndroid,
|
isAndroid: isAndroidHost,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +118,12 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
VoiceSectionHeader(l10n.voiceModeLabel),
|
VoiceSectionHeader(l10n.voiceModeLabel),
|
||||||
SegmentedButton<rust.BridgeTransmitMode>(
|
SegmentedButton<rust.BridgeTransmitMode>(
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
style: voiceSegmentedButtonStyle(theme),
|
||||||
segments: transmitModeSegments,
|
// DEC-030: hide the voice-activity segment on hosts
|
||||||
|
// that ship no Chanora-owned VAD pipeline (iOS,
|
||||||
|
// macOS, web).
|
||||||
|
segments: transmitModeSegmentsFor(
|
||||||
|
voiceActivityAvailable: voiceActivityTransmitAvailable,
|
||||||
|
),
|
||||||
selected: {_mode},
|
selected: {_mode},
|
||||||
onSelectionChanged: (s) => setState(() => _mode = s.first),
|
onSelectionChanged: (s) => setState(() => _mode = s.first),
|
||||||
),
|
),
|
||||||
@@ -184,92 +179,10 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
const VoiceSectionHeader('Audio processing'),
|
const VoiceSectionHeader('Audio processing'),
|
||||||
|
|
||||||
// Android HW/SW selector
|
AudioProcessingPanel(
|
||||||
if (_isAndroid) ...[
|
config: _audioProcessing,
|
||||||
const VoiceSubHeader('Processing backend'),
|
update: (mutation) => setState(mutation),
|
||||||
SegmentedButton<bool>(
|
),
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
|
||||||
segments: androidProcessingSegments,
|
|
||||||
selected: {_audioProcessing.preferHardware},
|
|
||||||
onSelectionChanged: (s) =>
|
|
||||||
setState(() => _audioProcessing.preferHardware = s.first),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_audioProcessing.preferHardware
|
|
||||||
? 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.'
|
|
||||||
: 'Android software mode applies the full WebRTC APM control set.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
|
|
||||||
// DSP toggles
|
|
||||||
const VoiceSubHeader('DSP stages'),
|
|
||||||
if (_isIos) ...[
|
|
||||||
Text(
|
|
||||||
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
|
||||||
'hidden here; only settings that still affect the shipping '
|
|
||||||
'iOS path are shown.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
if (!_isIos &&
|
|
||||||
(!_isAndroid || androidShowsNsControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
label: 'Noise suppression (NS)',
|
|
||||||
subtitle: 'Wiener filter · stationary noise',
|
|
||||||
value: _audioProcessing.nsEnabled,
|
|
||||||
onChanged: (v) =>
|
|
||||||
setState(() => _audioProcessing.nsEnabled = v),
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!_isAndroid || androidShowsAecControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
label: 'Echo cancellation (AEC3)',
|
|
||||||
subtitle: _isAndroid
|
|
||||||
? (_audioProcessing.preferHardware
|
|
||||||
? 'Prefers device/OS effect; WebRTC AEC3 fallback when binding is unavailable'
|
|
||||||
: 'WebRTC AEC3 · adaptive filter')
|
|
||||||
: (_isMacOS
|
|
||||||
? 'Managed by platform VPIO'
|
|
||||||
: 'WebRTC AEC3 · adaptive filter'),
|
|
||||||
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
|
||||||
onChanged: _isMacOS
|
|
||||||
? null
|
|
||||||
: (v) => setState(() => _audioProcessing.aecEnabled = v),
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!_isAndroid || androidShowsAgcControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
label: 'Auto gain control (AGC2)',
|
|
||||||
subtitle: 'RNN VAD-gated · -18 dBFS target',
|
|
||||||
value: _audioProcessing.agcEnabled,
|
|
||||||
onChanged: (v) =>
|
|
||||||
setState(() => _audioProcessing.agcEnabled = v),
|
|
||||||
),
|
|
||||||
if (!_isAndroid || androidShowsHpfControl(_audioProcessing))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
label: 'High-pass filter (HPF)',
|
|
||||||
subtitle: '80 Hz Butterworth · DC removal',
|
|
||||||
value: _audioProcessing.hpfEnabled,
|
|
||||||
onChanged: (v) =>
|
|
||||||
setState(() => _audioProcessing.hpfEnabled = v),
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!_isAndroid || androidShowsLimiterControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
label: 'Peak limiter',
|
|
||||||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
|
||||||
value: _audioProcessing.limiterEnabled,
|
|
||||||
onChanged: (v) =>
|
|
||||||
setState(() => _audioProcessing.limiterEnabled = v),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (isTalkPowerBlocked(
|
if (isTalkPowerBlocked(
|
||||||
talkPower: widget.talkPower,
|
talkPower: widget.talkPower,
|
||||||
@@ -284,22 +197,6 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// ── VAD ────────────────────────────────────────────────
|
|
||||||
const Divider(height: 24),
|
|
||||||
const VoiceSectionHeader('Voice activity detection (VAD)'),
|
|
||||||
|
|
||||||
const VoiceSubHeader('Backend'),
|
|
||||||
SegmentedButton<rust.BridgeVadBackend>(
|
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
|
||||||
segments: _isDesktopSileroVadHost
|
|
||||||
? desktopVadBackendSegments
|
|
||||||
: vadBackendSegments,
|
|
||||||
selected: {_audioProcessing.vadBackend},
|
|
||||||
onSelectionChanged: (s) =>
|
|
||||||
setState(() => _audioProcessing.vadBackend = s.first),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// ── PTT capability badge ────────────────────────────────
|
// ── PTT capability badge ────────────────────────────────
|
||||||
if (_mode == rust.BridgeTransmitMode.ptt &&
|
if (_mode == rust.BridgeTransmitMode.ptt &&
|
||||||
widget.pttLevel.isNotEmpty) ...[
|
widget.pttLevel.isNotEmpty) ...[
|
||||||
@@ -313,23 +210,25 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// ── Audio output route picker (mobile only) ─────────────
|
// ── Audio output route picker (mobile only) ─────────────
|
||||||
if (_isAndroid || _isIos) ...[
|
if (isAndroidHost || isIosHost) ...[
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
const VoiceSectionHeader('Audio output'),
|
const VoiceSectionHeader('Audio output'),
|
||||||
const AudioOutputTile(),
|
const AudioOutputTile(),
|
||||||
],
|
],
|
||||||
|
|
||||||
// ── Audio devices (desktop only, SRS-026) ──────────────
|
// ── Audio devices (desktop only, SRS-026) ──────────────
|
||||||
if (!_isAndroid && !_isIos) ...[
|
if (!isAndroidHost && !isIosHost) ...[
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
const VoiceSectionHeader('Audio devices'),
|
const VoiceSectionHeader('Audio devices'),
|
||||||
const AudioDeviceListTile(
|
AudioDeviceListTile(
|
||||||
label: 'Input',
|
label: 'Input',
|
||||||
kind: AudioDeviceKind.input,
|
kind: AudioDeviceKind.input,
|
||||||
|
onDeviceChanged: widget.onInputDeviceChanged,
|
||||||
),
|
),
|
||||||
const AudioDeviceListTile(
|
AudioDeviceListTile(
|
||||||
label: 'Output',
|
label: 'Output',
|
||||||
kind: AudioDeviceKind.output,
|
kind: AudioDeviceKind.output,
|
||||||
|
onDeviceChanged: widget.onOutputDeviceChanged,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'audio_processing_config_state.dart';
|
||||||
|
import 'voice_platform.dart';
|
||||||
import '../src/rust/api.dart' as rust;
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
/// Shared compact style for voice settings segmented buttons.
|
/// Shared compact style for voice settings segmented buttons.
|
||||||
@@ -10,7 +15,11 @@ ButtonStyle voiceSegmentedButtonStyle(ThemeData theme) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transmit mode selector segments.
|
/// Transmit mode selector segments — full set, all three modes.
|
||||||
|
///
|
||||||
|
/// This list is kept stable for legacy call sites and tests; UI
|
||||||
|
/// surfaces that must respect DEC-030 platform gating should prefer
|
||||||
|
/// [transmitModeSegmentsFor] with [voiceActivityTransmitAvailable].
|
||||||
const transmitModeSegments = [
|
const transmitModeSegments = [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: rust.BridgeTransmitMode.ptt,
|
value: rust.BridgeTransmitMode.ptt,
|
||||||
@@ -29,6 +38,43 @@ const transmitModeSegments = [
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Transmit mode selector segments, optionally dropping the
|
||||||
|
/// voice-activity entry on hosts that do not ship a VAD pipeline.
|
||||||
|
///
|
||||||
|
/// Voice activity transmit is gated by [voiceActivityTransmitAvailable]
|
||||||
|
/// because the underlying VAD pipeline ships only on Windows, Linux, and
|
||||||
|
/// Android per DEC-030. Builds for unsupported platforms (iOS, macOS,
|
||||||
|
/// web) drop the VAD segment entirely so the UI never advertises a
|
||||||
|
/// transmit mode the engine cannot honour.
|
||||||
|
List<ButtonSegment<rust.BridgeTransmitMode>> transmitModeSegmentsFor({
|
||||||
|
required bool voiceActivityAvailable,
|
||||||
|
}) {
|
||||||
|
if (voiceActivityAvailable) return transmitModeSegments;
|
||||||
|
return const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: rust.BridgeTransmitMode.ptt,
|
||||||
|
label: Text('PTT'),
|
||||||
|
icon: Icon(Icons.radio_button_checked, size: 14),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: rust.BridgeTransmitMode.continuous,
|
||||||
|
label: Text('Always'),
|
||||||
|
icon: Icon(Icons.podcasts, size: 14),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this host advertises VAD transmit per DEC-030.
|
||||||
|
///
|
||||||
|
/// The desktop Silero ONNX + WebRTC fallback ships on Windows and
|
||||||
|
/// Linux; the Android Oboe + WebRTC path covers Android; iOS uses the
|
||||||
|
/// Apple CoreML Silero VAD pipeline via `vad::apple_coreml`. macOS is
|
||||||
|
/// still gated until its VAD pipeline is confirmed.
|
||||||
|
bool get voiceActivityTransmitAvailable {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
return Platform.isWindows || Platform.isLinux || Platform.isAndroid || Platform.isIOS;
|
||||||
|
}
|
||||||
|
|
||||||
/// Android hardware/WebRTC selector segments.
|
/// Android hardware/WebRTC selector segments.
|
||||||
const androidProcessingSegments = [
|
const androidProcessingSegments = [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
@@ -186,3 +232,173 @@ class AudioProcessingToggleRow extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typedef AudioFieldUpdater = void Function(VoidCallback mutation);
|
||||||
|
|
||||||
|
class AudioProcessingPanel extends StatelessWidget {
|
||||||
|
const AudioProcessingPanel({
|
||||||
|
super.key,
|
||||||
|
required this.config,
|
||||||
|
required this.update,
|
||||||
|
this.dense = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AudioProcessingConfigState config;
|
||||||
|
final AudioFieldUpdater update;
|
||||||
|
final bool dense;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isAndroid = isAndroidHost;
|
||||||
|
final isIos = isIosHost;
|
||||||
|
final isMacOS = isMacOsHost;
|
||||||
|
final isDesktopSilero = isDesktopSileroVadHost;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (isAndroid) ...[
|
||||||
|
if (!dense) const VoiceSubHeader('Processing backend'),
|
||||||
|
if (dense) _compactLabel(context, 'Processing backend'),
|
||||||
|
SegmentedButton<bool>(
|
||||||
|
style: voiceSegmentedButtonStyle(theme),
|
||||||
|
segments: androidProcessingSegments,
|
||||||
|
selected: {config.preferHardware},
|
||||||
|
onSelectionChanged: (s) {
|
||||||
|
update(() => config.preferHardware = s.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
config.preferHardware
|
||||||
|
? (dense
|
||||||
|
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
||||||
|
: 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.')
|
||||||
|
: (dense
|
||||||
|
? 'Software mode applies the full WebRTC APM stage set.'
|
||||||
|
: 'Android software mode applies the full WebRTC APM control set.'),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!dense) const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (!dense) const VoiceSubHeader('DSP stages'),
|
||||||
|
|
||||||
|
if (isIos) ...[
|
||||||
|
Text(
|
||||||
|
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
||||||
|
'hidden here; only settings that still affect the shipping '
|
||||||
|
'iOS path are shown.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (!isIos &&
|
||||||
|
(!isAndroid || androidShowsNsControl(config)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: dense,
|
||||||
|
label: dense ? 'Noise suppression' : 'Noise suppression (NS)',
|
||||||
|
subtitle: dense
|
||||||
|
? 'Wiener filter'
|
||||||
|
: 'Wiener filter · stationary noise',
|
||||||
|
value: config.nsEnabled,
|
||||||
|
onChanged: (v) => update(() => config.nsEnabled = v),
|
||||||
|
),
|
||||||
|
if (!isIos &&
|
||||||
|
(!isAndroid || androidShowsAecControl(config)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: dense,
|
||||||
|
label: dense ? 'Echo cancellation' : 'Echo cancellation (AEC3)',
|
||||||
|
subtitle: _aecSubtitle(isAndroid, isMacOS, config),
|
||||||
|
value: isMacOS ? true : config.aecEnabled,
|
||||||
|
onChanged:
|
||||||
|
isMacOS ? null : (v) => update(() => config.aecEnabled = v),
|
||||||
|
),
|
||||||
|
if (!isIos &&
|
||||||
|
(!isAndroid || androidShowsAgcControl(config)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: dense,
|
||||||
|
label: dense ? 'Auto gain control' : 'Auto gain control (AGC2)',
|
||||||
|
subtitle: dense
|
||||||
|
? 'AGC2 · -18 dBFS target'
|
||||||
|
: 'RNN VAD-gated · -18 dBFS target',
|
||||||
|
value: config.agcEnabled,
|
||||||
|
onChanged: (v) => update(() => config.agcEnabled = v),
|
||||||
|
),
|
||||||
|
if (!isAndroid || androidShowsHpfControl(config))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: dense,
|
||||||
|
label: dense ? 'High-pass filter' : 'High-pass filter (HPF)',
|
||||||
|
subtitle: dense
|
||||||
|
? '80 Hz · DC removal'
|
||||||
|
: '80 Hz Butterworth · DC removal',
|
||||||
|
value: config.hpfEnabled,
|
||||||
|
onChanged: (v) => update(() => config.hpfEnabled = v),
|
||||||
|
),
|
||||||
|
if (!isIos &&
|
||||||
|
(!isAndroid || androidShowsLimiterControl(config)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: dense,
|
||||||
|
label: 'Peak limiter',
|
||||||
|
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
||||||
|
value: config.limiterEnabled,
|
||||||
|
onChanged: (v) => update(() => config.limiterEnabled = v),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (!dense) ...[
|
||||||
|
const Divider(height: 24),
|
||||||
|
const VoiceSectionHeader('Voice activity detection (VAD)'),
|
||||||
|
const VoiceSubHeader('Backend'),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Voice activity detection (VAD)',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
],
|
||||||
|
SegmentedButton<rust.BridgeVadBackend>(
|
||||||
|
style: voiceSegmentedButtonStyle(theme),
|
||||||
|
segments:
|
||||||
|
isDesktopSilero ? desktopVadBackendSegments : vadBackendSegments,
|
||||||
|
selected: {config.vadBackend},
|
||||||
|
onSelectionChanged: (s) {
|
||||||
|
update(() => config.vadBackend = s.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!dense) const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _aecSubtitle(bool isAndroid, bool isMacOS, AudioProcessingConfigState c) {
|
||||||
|
if (isAndroid) {
|
||||||
|
return c.preferHardware
|
||||||
|
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
||||||
|
: 'WebRTC AEC3 · adaptive filter';
|
||||||
|
}
|
||||||
|
if (isMacOS) return 'Managed by platform VPIO';
|
||||||
|
return 'WebRTC AEC3 · adaptive filter';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _compactLabel(BuildContext context, String text) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
<string>Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking.</string>
|
<string>Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
|
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
|
||||||
|
<key>NSUserNotificationsUsageDescription</key>
|
||||||
|
<string>Chanora sends you a notification when another user pokes you.</string>
|
||||||
<key>NSBonjourServices</key>
|
<key>NSBonjourServices</key>
|
||||||
<array>
|
<array>
|
||||||
<string>_ts3._tcp</string>
|
<string>_ts3._tcp</string>
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
|
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "93.0.0"
|
version: "96.0.0"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
|
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.1"
|
version: "10.2.0"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -133,10 +133,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: code_assets
|
name: code_assets
|
||||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.2.1"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -197,10 +197,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dbus
|
name: dbus
|
||||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
version: "0.7.13"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -262,6 +262,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_local_notifications:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications
|
||||||
|
sha256: be38e3854d2baabcda8e16966a5fe8748cebb655bb94701494da0f052c2fc352
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "22.0.0"
|
||||||
|
flutter_local_notifications_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_linux
|
||||||
|
sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.0.1"
|
||||||
|
flutter_local_notifications_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_platform_interface
|
||||||
|
sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.0.0"
|
||||||
|
flutter_local_notifications_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_web
|
||||||
|
sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
flutter_local_notifications_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_windows
|
||||||
|
sha256: "5aeed973a0c1480706784fad05c5c3a911335ebb561b2274b47fe80b375201e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -321,18 +361,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: haptic_kit
|
name: haptic_kit
|
||||||
sha256: "39efffa513c9f8ce3cdded8a4423797f69d71c9281779b83727337f3ee1ed9b8"
|
sha256: "457f825a3413be2651954639bed27bb2987570f75d90c4e8e1cb9be62db2e59d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "2.0.2"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -469,14 +509,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
native_toolchain_c:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: native_toolchain_c
|
|
||||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.17.6"
|
|
||||||
nm:
|
nm:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -489,10 +521,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: objective_c
|
name: objective_c
|
||||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.3.0"
|
version: "9.4.1"
|
||||||
package_config:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -665,10 +697,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.23"
|
version: "2.4.25"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -794,6 +826,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.11"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.11.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -814,10 +854,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.30"
|
version: "6.3.32"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -926,10 +966,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9
|
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.2.0"
|
version: "6.3.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -942,10 +982,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: xml
|
name: xml
|
||||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.6.1"
|
version: "7.0.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -955,5 +995,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.5 <4.0.0"
|
dart: ">=3.12.0 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ dependencies:
|
|||||||
# DEC-003 iOS 13 floor; haptic_kit supports iOS 12+).
|
# DEC-003 iOS 13 floor; haptic_kit supports iOS 12+).
|
||||||
haptic_kit: ^1.0.0
|
haptic_kit: ^1.0.0
|
||||||
flutter_foreground_task: ^9.2.2
|
flutter_foreground_task: ^9.2.2
|
||||||
|
flutter_local_notifications: ^22.0.0
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
share_plus: ^13.1.0
|
share_plus: ^13.1.0
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ if [ ! -f "${BINARY}" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Flutter Debug builds split user code into a sibling `<App>.debug.dylib`
|
||||||
|
# alongside a tiny launcher executable; the @_cdecl symbols live in the
|
||||||
|
# dylib. Release/Profile builds put everything in the main executable.
|
||||||
|
# Prefer the dylib when both exist so the check verifies the slice that
|
||||||
|
# actually carries the symbols.
|
||||||
|
EXECUTABLE_DIR=$(dirname "${BINARY}")
|
||||||
|
EXECUTABLE_NAME=$(basename "${BINARY}")
|
||||||
|
DEBUG_DYLIB="${EXECUTABLE_DIR}/${EXECUTABLE_NAME}.debug.dylib"
|
||||||
|
if [ -f "${DEBUG_DYLIB}" ]; then
|
||||||
|
BINARY="${DEBUG_DYLIB}"
|
||||||
|
fi
|
||||||
|
|
||||||
REQUIRED_SYMBOLS="
|
REQUIRED_SYMBOLS="
|
||||||
_chanora_silero_vad_create
|
_chanora_silero_vad_create
|
||||||
_chanora_silero_vad_destroy
|
_chanora_silero_vad_destroy
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/services/hard_mute_owners.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('HardMuteOwners', () {
|
||||||
|
test('manual mute survives talk-power block and restore', () {
|
||||||
|
const owners = HardMuteOwners(manual: true);
|
||||||
|
|
||||||
|
final blocked = owners.copyWith(talkPower: true);
|
||||||
|
expect(blocked.effective, isTrue);
|
||||||
|
|
||||||
|
final restored = blocked.copyWith(talkPower: false);
|
||||||
|
expect(restored.manual, isTrue);
|
||||||
|
expect(restored.talkPower, isFalse);
|
||||||
|
expect(restored.effective, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('effective mute is the union of independent owners', () {
|
||||||
|
expect(const HardMuteOwners().effective, isFalse);
|
||||||
|
expect(const HardMuteOwners(manual: true).effective, isTrue);
|
||||||
|
expect(const HardMuteOwners(permission: true).effective, isTrue);
|
||||||
|
expect(const HardMuteOwners(talkPower: true).effective, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bridge mute does not convert talk-power owner into manual owner', () {
|
||||||
|
const owners = HardMuteOwners(talkPower: true);
|
||||||
|
|
||||||
|
final synced = owners.withBridgeManualMute(true);
|
||||||
|
|
||||||
|
expect(synced.manual, isFalse);
|
||||||
|
expect(synced.talkPower, isTrue);
|
||||||
|
expect(synced.effective, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/services/ios_audio_session_controller.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('IosAudioSessionController', () {
|
||||||
|
const channel = MethodChannel(iosAudioSessionChannelName);
|
||||||
|
final messenger = TestDefaultBinaryMessengerBinding
|
||||||
|
.instance.defaultBinaryMessenger;
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
messenger.setMockMethodCallHandler(channel, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel name matches Swift contract', () {
|
||||||
|
expect(iosAudioSessionChannelName, 'chanora/ios_audio_session');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate invokes activateVoiceSession on iOS', () async {
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final controller = IosAudioSessionController(
|
||||||
|
channel: channel,
|
||||||
|
isIos: true,
|
||||||
|
);
|
||||||
|
await controller.activate();
|
||||||
|
|
||||||
|
expect(calls.map((c) => c.method), ['activateVoiceSession']);
|
||||||
|
expect(calls.single.arguments, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deactivate invokes deactivateVoiceSession on iOS', () async {
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final controller = IosAudioSessionController(
|
||||||
|
channel: channel,
|
||||||
|
isIos: true,
|
||||||
|
);
|
||||||
|
await controller.deactivate();
|
||||||
|
|
||||||
|
expect(calls.map((c) => c.method), ['deactivateVoiceSession']);
|
||||||
|
expect(calls.single.arguments, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate is a no-op on non-iOS platforms', () async {
|
||||||
|
var invoked = false;
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
invoked = true;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final controller = IosAudioSessionController(
|
||||||
|
channel: channel,
|
||||||
|
isIos: false,
|
||||||
|
);
|
||||||
|
await controller.activate();
|
||||||
|
await controller.deactivate();
|
||||||
|
|
||||||
|
expect(invoked, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate swallows PlatformException so engine keeps running',
|
||||||
|
() async {
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
throw PlatformException(code: 'avaudiosession_failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
final controller = IosAudioSessionController(
|
||||||
|
channel: channel,
|
||||||
|
isIos: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(controller.activate(), completes);
|
||||||
|
await expectLater(controller.deactivate(), completes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate swallows MissingPluginException when channel is absent',
|
||||||
|
() async {
|
||||||
|
final controller = IosAudioSessionController(
|
||||||
|
channel: channel,
|
||||||
|
isIos: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(controller.activate(), completes);
|
||||||
|
await expectLater(controller.deactivate(), completes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/main.dart';
|
||||||
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('active poke chat suppresses same sender notification only', () {
|
||||||
|
final sender = BigInt.from(42);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isPokeSenderActiveChat(
|
||||||
|
chatOpen: true,
|
||||||
|
inlineChatTarget: rust.BridgeMessageTarget.poke(sender),
|
||||||
|
senderId: sender,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isPokeSenderActiveChat(
|
||||||
|
chatOpen: true,
|
||||||
|
inlineChatTarget: rust.BridgeMessageTarget.poke(BigInt.from(7)),
|
||||||
|
senderId: sender,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('active private chat also suppresses same sender poke notification', () {
|
||||||
|
final sender = BigInt.from(42);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isPokeSenderActiveChat(
|
||||||
|
chatOpen: true,
|
||||||
|
inlineChatTarget: rust.BridgeMessageTarget.client(sender),
|
||||||
|
senderId: sender,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isPokeSenderActiveChat(
|
||||||
|
chatOpen: false,
|
||||||
|
inlineChatTarget: rust.BridgeMessageTarget.client(sender),
|
||||||
|
senderId: sender,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/services/poke_notification_service.dart';
|
||||||
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
|
||||||
|
late List<MethodCall> calls;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
AndroidFlutterLocalNotificationsPlugin.registerWith();
|
||||||
|
calls = <MethodCall>[];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
return switch (call.method) {
|
||||||
|
'initialize' => true,
|
||||||
|
'requestNotificationsPermission' => true,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'show dispatches a silent poke notification with sender payload',
|
||||||
|
() async {
|
||||||
|
final service = PokeNotificationService();
|
||||||
|
|
||||||
|
await service.show(
|
||||||
|
senderName: 'Alice',
|
||||||
|
message: 'wake up',
|
||||||
|
senderId: BigInt.from(42),
|
||||||
|
strength: rust.BridgePokeStrength.strong,
|
||||||
|
);
|
||||||
|
|
||||||
|
final showCall = calls.singleWhere((call) => call.method == 'show');
|
||||||
|
final arguments = Map<Object?, Object?>.from(showCall.arguments as Map);
|
||||||
|
|
||||||
|
expect(arguments['id'], 42);
|
||||||
|
expect(arguments['title'], 'Poke from Alice');
|
||||||
|
expect(arguments['body'], 'wake up');
|
||||||
|
expect(arguments['payload'], 'poke:42');
|
||||||
|
final specifics = Map<Object?, Object?>.from(
|
||||||
|
arguments['platformSpecifics'] as Map,
|
||||||
|
);
|
||||||
|
expect(specifics['silent'], true);
|
||||||
|
expect(specifics['playSound'], false);
|
||||||
|
expect(specifics['groupKey'], 'chanora.pokes');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('show uses fallback body for empty poke messages', () async {
|
||||||
|
final service = PokeNotificationService();
|
||||||
|
|
||||||
|
await service.show(
|
||||||
|
senderName: 'Alice',
|
||||||
|
message: ' ',
|
||||||
|
senderId: BigInt.from(42),
|
||||||
|
strength: rust.BridgePokeStrength.strong,
|
||||||
|
);
|
||||||
|
|
||||||
|
final showCall = calls.singleWhere((call) => call.method == 'show');
|
||||||
|
final arguments = Map<Object?, Object?>.from(showCall.arguments as Map);
|
||||||
|
|
||||||
|
expect(arguments['title'], 'Poke from Alice');
|
||||||
|
expect(arguments['body'], 'Alice pokes you');
|
||||||
|
expect(arguments['payload'], 'poke:42');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/services/poke_preferences_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PokePreferencesService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
service = PokePreferencesService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads defaults when preferences are unset', () async {
|
||||||
|
await service.load();
|
||||||
|
|
||||||
|
expect(service.pokesEnabled.value, isTrue);
|
||||||
|
expect(service.mutedSenders.value, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists global enabled state', () async {
|
||||||
|
await service.load();
|
||||||
|
await service.setPokesEnabled(false);
|
||||||
|
|
||||||
|
final reloaded = PokePreferencesService();
|
||||||
|
await reloaded.load();
|
||||||
|
|
||||||
|
expect(reloaded.pokesEnabled.value, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists muted senders and removes them on unmute', () async {
|
||||||
|
await service.load();
|
||||||
|
final alice = BigInt.from(42);
|
||||||
|
final bob = BigInt.from(7);
|
||||||
|
|
||||||
|
await service.muteSender(alice);
|
||||||
|
await service.muteSender(bob);
|
||||||
|
await service.unmuteSender(alice);
|
||||||
|
|
||||||
|
final reloaded = PokePreferencesService();
|
||||||
|
await reloaded.load();
|
||||||
|
|
||||||
|
expect(reloaded.mutedSenders.value, {bob});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isMuted reflects in-memory changes synchronously', () async {
|
||||||
|
await service.load();
|
||||||
|
final sender = BigInt.from(99);
|
||||||
|
|
||||||
|
expect(service.isMuted(sender), isFalse);
|
||||||
|
|
||||||
|
await service.muteSender(sender);
|
||||||
|
expect(service.isMuted(sender), isTrue);
|
||||||
|
|
||||||
|
await service.unmuteSender(sender);
|
||||||
|
expect(service.isMuted(sender), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -67,4 +67,44 @@ void main() {
|
|||||||
|
|
||||||
expect(settings.themeMode, UiThemeMode.system);
|
expect(settings.themeMode, UiThemeMode.system);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('loads null voice settings when unset', () async {
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.transmitModeIndex, isNull);
|
||||||
|
expect(settings.releaseTailMs, isNull);
|
||||||
|
expect(settings.inputDeviceId, isNull);
|
||||||
|
expect(settings.outputDeviceId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saves and loads transmit mode index', () async {
|
||||||
|
await service.saveTransmitModeIndex(1);
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.transmitModeIndex, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saves and loads release tail ms', () async {
|
||||||
|
await service.saveReleaseTailMs(300);
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.releaseTailMs, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saves and loads audio device ids', () async {
|
||||||
|
await service.saveInputDeviceId('input-123');
|
||||||
|
await service.saveOutputDeviceId('output-456');
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.inputDeviceId, 'input-123');
|
||||||
|
expect(settings.outputDeviceId, 'output-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes audio device id when set to null', () async {
|
||||||
|
await service.saveInputDeviceId('input-123');
|
||||||
|
await service.saveInputDeviceId(null);
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.inputDeviceId, isNull);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/services/voice_join_ordering.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('joinVoiceChannelWithIosAudioSession', () {
|
||||||
|
test('activates the iOS audio session before Rust voiceJoin', () async {
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
await joinVoiceChannelWithIosAudioSession(
|
||||||
|
channelId: BigInt.from(42),
|
||||||
|
password: 'secret',
|
||||||
|
activateIosAudioSession: () async {
|
||||||
|
calls.add('activateIosAudioSession');
|
||||||
|
},
|
||||||
|
deactivateIosAudioSession: () async {
|
||||||
|
calls.add('deactivateIosAudioSession');
|
||||||
|
},
|
||||||
|
voiceJoin: ({required channelId, required password}) async {
|
||||||
|
expect(channelId, BigInt.from(42));
|
||||||
|
expect(password, 'secret');
|
||||||
|
calls.add('voiceJoin');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls, ['activateIosAudioSession', 'voiceJoin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deactivates the iOS audio session when Rust voiceJoin fails',
|
||||||
|
() async {
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
joinVoiceChannelWithIosAudioSession(
|
||||||
|
channelId: BigInt.from(42),
|
||||||
|
password: '',
|
||||||
|
activateIosAudioSession: () async {
|
||||||
|
calls.add('activateIosAudioSession');
|
||||||
|
},
|
||||||
|
deactivateIosAudioSession: () async {
|
||||||
|
calls.add('deactivateIosAudioSession');
|
||||||
|
},
|
||||||
|
voiceJoin: ({required channelId, required password}) async {
|
||||||
|
calls.add('voiceJoin');
|
||||||
|
throw StateError('join rejected');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls, [
|
||||||
|
'activateIosAudioSession',
|
||||||
|
'voiceJoin',
|
||||||
|
'deactivateIosAudioSession',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'keeps the iOS audio session active when voiceJoin throws but the '
|
||||||
|
'error is recognised as already-in-channel (treated as success); '
|
||||||
|
'still rethrows so the caller runs its success-on-already-joined branch',
|
||||||
|
() async {
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
joinVoiceChannelWithIosAudioSession(
|
||||||
|
channelId: BigInt.from(42),
|
||||||
|
password: '',
|
||||||
|
activateIosAudioSession: () async {
|
||||||
|
calls.add('activateIosAudioSession');
|
||||||
|
},
|
||||||
|
deactivateIosAudioSession: () async {
|
||||||
|
calls.add('deactivateIosAudioSession');
|
||||||
|
},
|
||||||
|
voiceJoin: ({required channelId, required password}) async {
|
||||||
|
calls.add('voiceJoin');
|
||||||
|
throw _FakeAlreadyInChannel();
|
||||||
|
},
|
||||||
|
isJoinSuccess: (error) => error is _FakeAlreadyInChannel,
|
||||||
|
),
|
||||||
|
throwsA(isA<_FakeAlreadyInChannel>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls, ['activateIosAudioSession', 'voiceJoin']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'deactivates the iOS audio session when isJoinSuccess returns false '
|
||||||
|
'for a non-success error',
|
||||||
|
() async {
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
joinVoiceChannelWithIosAudioSession(
|
||||||
|
channelId: BigInt.from(42),
|
||||||
|
password: '',
|
||||||
|
activateIosAudioSession: () async {
|
||||||
|
calls.add('activateIosAudioSession');
|
||||||
|
},
|
||||||
|
deactivateIosAudioSession: () async {
|
||||||
|
calls.add('deactivateIosAudioSession');
|
||||||
|
},
|
||||||
|
voiceJoin: ({required channelId, required password}) async {
|
||||||
|
calls.add('voiceJoin');
|
||||||
|
throw StateError('join rejected');
|
||||||
|
},
|
||||||
|
isJoinSuccess: (error) => error is _FakeAlreadyInChannel,
|
||||||
|
),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls, [
|
||||||
|
'activateIosAudioSession',
|
||||||
|
'voiceJoin',
|
||||||
|
'deactivateIosAudioSession',
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeAlreadyInChannel implements Exception {}
|
||||||
@@ -455,7 +455,7 @@ void main() {
|
|||||||
channelName: '',
|
channelName: '',
|
||||||
clientName: 'Alpha',
|
clientName: 'Alpha',
|
||||||
),
|
),
|
||||||
'Poke message...',
|
'Poke message optional...',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -737,6 +737,179 @@ void main() {
|
|||||||
refresh.dispose();
|
refresh.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('evaluates target-aware chat message send policy', () {
|
||||||
|
final clientTarget = rust.BridgeMessageTarget.client(BigInt.from(2));
|
||||||
|
final pokeTarget = rust.BridgeMessageTarget.poke(BigInt.from(2));
|
||||||
|
|
||||||
|
expect(canSendChatMessage(pokeTarget, null, ''), isTrue);
|
||||||
|
expect(canSendChatMessage(pokeTarget, null, ' '), isTrue);
|
||||||
|
expect(canSendChatMessage(pokeTarget, null, 'wake up'), isTrue);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
canSendChatMessage(const rust.BridgeMessageTarget.server(), null, ''),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
canSendChatMessage(
|
||||||
|
const rust.BridgeMessageTarget.channel(),
|
||||||
|
BigInt.from(10),
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(canSendChatMessage(clientTarget, null, ''), isFalse);
|
||||||
|
expect(
|
||||||
|
canSendChatMessage(
|
||||||
|
const rust.BridgeMessageTarget.channel(),
|
||||||
|
null,
|
||||||
|
'hello',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
canSendChatMessage(
|
||||||
|
const rust.BridgeMessageTarget.channel(),
|
||||||
|
BigInt.from(10),
|
||||||
|
'hello',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('poke detail sends an empty poke when the composer is empty', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
String? sentMessage;
|
||||||
|
rust.BridgeMessageTarget? sentTarget;
|
||||||
|
final messages = <ChatEntry>[];
|
||||||
|
final target = rust.BridgeMessageTarget.poke(BigInt.from(2));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: ChatDetailView(
|
||||||
|
messages: messages,
|
||||||
|
snapshot: snapshot(
|
||||||
|
channels: const [],
|
||||||
|
clients: [
|
||||||
|
client(id: BigInt.one, name: 'Me', channelId: BigInt.zero),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
target: target,
|
||||||
|
clientName: 'Alpha',
|
||||||
|
currentChannelId: null,
|
||||||
|
channelName: '',
|
||||||
|
sendChatMessage: ({required message, required target}) async {
|
||||||
|
sentMessage = message;
|
||||||
|
sentTarget = target;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byTooltip('Poke'), findsOneWidget);
|
||||||
|
expect(find.byTooltip('Send'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Poke'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(sentMessage, '');
|
||||||
|
expect(sentTarget, target);
|
||||||
|
expect(messages, hasLength(1));
|
||||||
|
expect(messages.single.isPoke, isTrue);
|
||||||
|
expect(messages.single.message, '');
|
||||||
|
expect(find.textContaining('You poked "Alpha"'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('poke detail sends typed optional poke message', (tester) async {
|
||||||
|
String? sentMessage;
|
||||||
|
rust.BridgeMessageTarget? sentTarget;
|
||||||
|
final messages = <ChatEntry>[];
|
||||||
|
final target = rust.BridgeMessageTarget.poke(BigInt.from(2));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: ChatDetailView(
|
||||||
|
messages: messages,
|
||||||
|
snapshot: snapshot(
|
||||||
|
channels: const [],
|
||||||
|
clients: [
|
||||||
|
client(id: BigInt.one, name: 'Me', channelId: BigInt.zero),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
target: target,
|
||||||
|
clientName: 'Alpha',
|
||||||
|
currentChannelId: null,
|
||||||
|
channelName: '',
|
||||||
|
sendChatMessage: ({required message, required target}) async {
|
||||||
|
sentMessage = message;
|
||||||
|
sentTarget = target;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'wake up');
|
||||||
|
await tester.tap(find.byTooltip('Poke'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(sentMessage, 'wake up');
|
||||||
|
expect(sentTarget, target);
|
||||||
|
expect(messages.single.message, 'wake up');
|
||||||
|
expect(
|
||||||
|
find.textContaining('You poked "Alpha" with message: wake up'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('channel detail blocks empty sends with a joined channel', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var sendCount = 0;
|
||||||
|
final messages = <ChatEntry>[];
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: ChatDetailView(
|
||||||
|
messages: messages,
|
||||||
|
snapshot: snapshot(
|
||||||
|
channels: [channel(BigInt.from(10), 'Lobby')],
|
||||||
|
clients: [
|
||||||
|
client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
target: const rust.BridgeMessageTarget.channel(),
|
||||||
|
clientName: '',
|
||||||
|
currentChannelId: BigInt.from(10),
|
||||||
|
channelName: 'Lobby',
|
||||||
|
sendChatMessage: ({required message, required target}) async {
|
||||||
|
sendCount++;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byTooltip('Send'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Send'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(sendCount, 0);
|
||||||
|
expect(messages, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
test('blocks channel chat when no voice channel is joined', () {
|
test('blocks channel chat when no voice channel is joined', () {
|
||||||
expect(
|
expect(
|
||||||
canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null),
|
canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null),
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
|
import 'package:chanora_flutter/widgets/connect_widgets.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ConnectForm', () {
|
||||||
|
late TextEditingController hostCtl;
|
||||||
|
late TextEditingController nickCtl;
|
||||||
|
late TextEditingController passwordCtl;
|
||||||
|
|
||||||
|
Widget buildForm({
|
||||||
|
VoidCallback? onConnect,
|
||||||
|
VoidCallback? onAddBookmark,
|
||||||
|
}) {
|
||||||
|
return MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: ConnectForm(
|
||||||
|
hostCtl: hostCtl,
|
||||||
|
nickCtl: nickCtl,
|
||||||
|
passwordCtl: passwordCtl,
|
||||||
|
onConnect: onConnect ?? () {},
|
||||||
|
onAddBookmark: onAddBookmark ?? () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
hostCtl = TextEditingController();
|
||||||
|
nickCtl = TextEditingController();
|
||||||
|
passwordCtl = TextEditingController();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
hostCtl.dispose();
|
||||||
|
nickCtl.dispose();
|
||||||
|
passwordCtl.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders all three text fields', (tester) async {
|
||||||
|
await tester.pumpWidget(buildForm());
|
||||||
|
|
||||||
|
expect(find.byType(TextField), findsNWidgets(3));
|
||||||
|
expect(find.byIcon(Icons.dns_outlined), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.login), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.bookmark_add_outlined), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('connect callback fires on button tap', (tester) async {
|
||||||
|
var connected = false;
|
||||||
|
await tester.pumpWidget(buildForm(
|
||||||
|
onConnect: () => connected = true,
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.login));
|
||||||
|
expect(connected, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('bookmark callback fires on button tap', (tester) async {
|
||||||
|
var bookmarked = false;
|
||||||
|
await tester.pumpWidget(buildForm(
|
||||||
|
onAddBookmark: () => bookmarked = true,
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.bookmark_add_outlined));
|
||||||
|
expect(bookmarked, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('host field lowercases and strips whitespace', (tester) async {
|
||||||
|
await tester.pumpWidget(buildForm());
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, 'host[:port]'),
|
||||||
|
' MyServer.COM ',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(hostCtl.text, 'myserver.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('password field is obscured', (tester) async {
|
||||||
|
await tester.pumpWidget(buildForm());
|
||||||
|
|
||||||
|
final passwordField = tester.widgetList<TextField>(
|
||||||
|
find.byType(TextField),
|
||||||
|
).last;
|
||||||
|
expect(passwordField.obscureText, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('form uses outlined border decoration', (tester) async {
|
||||||
|
await tester.pumpWidget(buildForm());
|
||||||
|
|
||||||
|
final fields = tester.widgetList<TextField>(find.byType(TextField));
|
||||||
|
for (final field in fields) {
|
||||||
|
final decoration = field.decoration as InputDecoration;
|
||||||
|
expect(decoration.border, isA<OutlineInputBorder>());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('BookmarkList', () {
|
||||||
|
Widget buildBookmarkList({
|
||||||
|
required List<rust.BridgeBookmark> bookmarks,
|
||||||
|
ValueChanged<rust.BridgeBookmark>? onConnect,
|
||||||
|
ValueChanged<rust.BridgeBookmark>? onDelete,
|
||||||
|
}) {
|
||||||
|
return MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
child: BookmarkList(
|
||||||
|
bookmarks: bookmarks,
|
||||||
|
onConnect: onConnect ?? (_) {},
|
||||||
|
onDelete: onDelete ?? (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const testBookmark = rust.BridgeBookmark(
|
||||||
|
id: 1,
|
||||||
|
displayName: 'My Server',
|
||||||
|
host: 'ts.example.com',
|
||||||
|
nickname: 'TestUser',
|
||||||
|
password: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const testBookmark2 = rust.BridgeBookmark(
|
||||||
|
id: 2,
|
||||||
|
displayName: 'Work Server',
|
||||||
|
host: 'work.ts.com',
|
||||||
|
nickname: 'WorkNick',
|
||||||
|
password: 'secret',
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('shows empty message when no bookmarks', (tester) async {
|
||||||
|
await tester.pumpWidget(buildBookmarkList(bookmarks: const []));
|
||||||
|
|
||||||
|
expect(find.byType(BookmarkList), findsOneWidget);
|
||||||
|
expect(find.byType(Card), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders bookmark cards with name and host', (tester) async {
|
||||||
|
await tester.pumpWidget(buildBookmarkList(
|
||||||
|
bookmarks: const [testBookmark],
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.text('My Server'), findsOneWidget);
|
||||||
|
expect(find.text('ts.example.com — TestUser'), findsOneWidget);
|
||||||
|
expect(find.byType(Card), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders multiple bookmarks', (tester) async {
|
||||||
|
await tester.pumpWidget(buildBookmarkList(
|
||||||
|
bookmarks: const [testBookmark, testBookmark2],
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.text('My Server'), findsOneWidget);
|
||||||
|
expect(find.text('Work Server'), findsOneWidget);
|
||||||
|
expect(find.byType(Card), findsNWidgets(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('connect callback fires with correct bookmark', (tester) async {
|
||||||
|
rust.BridgeBookmark? connectedBookmark;
|
||||||
|
await tester.pumpWidget(buildBookmarkList(
|
||||||
|
bookmarks: const [testBookmark],
|
||||||
|
onConnect: (b) => connectedBookmark = b,
|
||||||
|
));
|
||||||
|
|
||||||
|
final connectButtons = find.byIcon(Icons.login);
|
||||||
|
await tester.tap(connectButtons.first);
|
||||||
|
|
||||||
|
expect(connectedBookmark, testBookmark);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('delete callback fires with correct bookmark', (tester) async {
|
||||||
|
rust.BridgeBookmark? deletedBookmark;
|
||||||
|
await tester.pumpWidget(buildBookmarkList(
|
||||||
|
bookmarks: const [testBookmark],
|
||||||
|
onDelete: (b) => deletedBookmark = b,
|
||||||
|
));
|
||||||
|
|
||||||
|
final deleteButtons = find.byIcon(Icons.delete_outline);
|
||||||
|
await tester.tap(deleteButtons.first);
|
||||||
|
|
||||||
|
expect(deletedBookmark, testBookmark);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('each bookmark card has connect and delete buttons', (tester) async {
|
||||||
|
await tester.pumpWidget(buildBookmarkList(
|
||||||
|
bookmarks: const [testBookmark],
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.login), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.delete_outline), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:chanora_flutter/services/poke_notification_service.dart';
|
||||||
|
import 'package:chanora_flutter/services/poke_preferences_service.dart';
|
||||||
|
import 'package:chanora_flutter/widgets/poke_notification_settings.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('toggles poke notifications and unmutes senders', (tester) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final preferences = PokePreferencesService();
|
||||||
|
await preferences.load();
|
||||||
|
await preferences.muteSender(BigInt.from(42));
|
||||||
|
addTearDown(preferences.dispose);
|
||||||
|
|
||||||
|
final notificationService = PokeNotificationService();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: PokeNotificationSettingsDialog(
|
||||||
|
preferences: preferences,
|
||||||
|
notificationService: notificationService,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Poke notifications'), findsOneWidget);
|
||||||
|
expect(find.text('Client ID 42'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(Switch));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(preferences.pokesEnabled.value, isFalse);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Unmute'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(preferences.isMuted(BigInt.from(42)), isFalse);
|
||||||
|
expect(find.text('No muted poke senders.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
|
import 'package:chanora_flutter/widgets/voice_bar.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const defaultStats = rust.BridgeAudioStats(
|
||||||
|
framesSent: 100,
|
||||||
|
framesReceived: 200,
|
||||||
|
pttActive: false,
|
||||||
|
inputLevel: -30.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget buildVoiceBar({
|
||||||
|
bool inChannel = true,
|
||||||
|
rust.BridgeTransmitMode transmitMode = rust.BridgeTransmitMode.ptt,
|
||||||
|
bool hardMute = false,
|
||||||
|
bool outputMuted = false,
|
||||||
|
bool talkPowerBlocked = false,
|
||||||
|
int releaseTailMs = 150,
|
||||||
|
String channelName = 'Test Channel',
|
||||||
|
rust.BridgeAudioStats? audioStats = defaultStats,
|
||||||
|
double? inputLevel,
|
||||||
|
String pttLevel = 'L1WindowsHook',
|
||||||
|
String pttBackendId = 'windows-raw-input',
|
||||||
|
String pttBoundInputClass = 'keyboard',
|
||||||
|
String pttBoundKeyLabel = 'Space',
|
||||||
|
VoidCallback? onConfigure,
|
||||||
|
ValueChanged<bool>? onPttHeldChanged,
|
||||||
|
}) {
|
||||||
|
return MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
child: VoiceBar(
|
||||||
|
inChannel: inChannel,
|
||||||
|
transmitMode: transmitMode,
|
||||||
|
hardMute: hardMute,
|
||||||
|
outputMuted: outputMuted,
|
||||||
|
talkPowerBlocked: talkPowerBlocked,
|
||||||
|
releaseTailMs: releaseTailMs,
|
||||||
|
channelName: channelName,
|
||||||
|
audioStats: audioStats,
|
||||||
|
inputLevel: inputLevel,
|
||||||
|
pttLevel: pttLevel,
|
||||||
|
pttBackendId: pttBackendId,
|
||||||
|
pttBoundInputClass: pttBoundInputClass,
|
||||||
|
pttBoundKeyLabel: pttBoundKeyLabel,
|
||||||
|
onConfigure: onConfigure ?? () {},
|
||||||
|
onPttHeldChanged: onPttHeldChanged ?? (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('VoiceBar renders in PTT mode with stats', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar());
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.radio_button_checked), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.tune), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar renders in continuous mode', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
transmitMode: rust.BridgeTransmitMode.continuous,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.podcasts), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar shows channel status when in channel', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(inChannel: true));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar hides channel status when not in channel', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
inChannel: false,
|
||||||
|
channelName: '',
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar shows talk power blocked indicator', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
talkPowerBlocked: true,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.text('Insufficient talk power'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar shows audio stats line when stats available', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar());
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar hides audio stats line when stats null', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
audioStats: null,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar onConfigure callback fires', (tester) async {
|
||||||
|
var configured = false;
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
onConfigure: () => configured = true,
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.tune));
|
||||||
|
expect(configured, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar renders PTT capability badge in PTT mode', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
transmitMode: rust.BridgeTransmitMode.ptt,
|
||||||
|
pttLevel: 'L1WindowsHook',
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.public), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar does not render PTT badge in continuous mode', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
transmitMode: rust.BridgeTransmitMode.continuous,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.public), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar renders with active PTT state', (tester) async {
|
||||||
|
const activeStats = rust.BridgeAudioStats(
|
||||||
|
framesSent: 500,
|
||||||
|
framesReceived: 300,
|
||||||
|
pttActive: true,
|
||||||
|
inputLevel: -10.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildVoiceBar(audioStats: activeStats));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar renders with input level override', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
inputLevel: -20.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar renders with null input level', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
audioStats: null,
|
||||||
|
inputLevel: null,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:chanora_flutter/widgets/voice_compact.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('touch PTT releases when disposed while held', (tester) async {
|
||||||
|
final heldChanges = <bool>[];
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: VoicePttButton(
|
||||||
|
active: false,
|
||||||
|
onHeldChanged: heldChanges.add,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final center = tester.getCenter(find.byType(VoicePttButton));
|
||||||
|
final gesture = await tester.startGesture(center);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(heldChanges, [true]);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
|
||||||
|
|
||||||
|
expect(heldChanges, [true, false]);
|
||||||
|
|
||||||
|
await gesture.cancel();
|
||||||
|
|
||||||
|
expect(heldChanges, [true, false]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,24 @@ void main() {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('gated transmit mode segments drop VAD when unsupported', () {
|
||||||
|
expect(
|
||||||
|
transmitModeSegmentsFor(voiceActivityAvailable: false).map((s) => s.value),
|
||||||
|
[rust.BridgeTransmitMode.ptt, rust.BridgeTransmitMode.continuous],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gated transmit mode segments include VAD when supported', () {
|
||||||
|
expect(
|
||||||
|
transmitModeSegmentsFor(voiceActivityAvailable: true).map((s) => s.value),
|
||||||
|
[
|
||||||
|
rust.BridgeTransmitMode.ptt,
|
||||||
|
rust.BridgeTransmitMode.continuous,
|
||||||
|
rust.BridgeTransmitMode.voiceActivity,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('shared Android processing segments expose hardware and WebRTC', () {
|
test('shared Android processing segments expose hardware and WebRTC', () {
|
||||||
expect(androidProcessingSegments.map((s) => s.value), [true, false]);
|
expect(androidProcessingSegments.map((s) => s.value), [true, false]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
|
import 'package:chanora_flutter/widgets/voice_settings.dart';
|
||||||
|
|
||||||
|
rust.BridgeAudioProcessingConfig _defaultConfig() {
|
||||||
|
return rust.BridgeAudioProcessingConfig(
|
||||||
|
route: rust.BridgeAudioRoute.unknown,
|
||||||
|
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||||
|
processingBackend: rust.BridgeAudioBackend.webrtcApm,
|
||||||
|
vadBackend: rust.BridgeVadBackend.sileroOnnx,
|
||||||
|
aec: rust.BridgeEffectOwner.webrtcApm,
|
||||||
|
ns: rust.BridgeEffectOwner.webrtcApm,
|
||||||
|
agc: rust.BridgeEffectOwner.webrtcApm,
|
||||||
|
hpfEnabled: true,
|
||||||
|
limiterEnabled: true,
|
||||||
|
vadHangoverMs: 500,
|
||||||
|
vadPreRollMs: 160,
|
||||||
|
vadMinTxMs: 200,
|
||||||
|
debugWavDumpEnabled: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('VoiceSettingsResult', () {
|
||||||
|
test('stores PTT mode result', () {
|
||||||
|
final config = _defaultConfig();
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.ptt,
|
||||||
|
releaseTailMs: 200,
|
||||||
|
bindKeyRequested: true,
|
||||||
|
audioConfig: config,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.mode, rust.BridgeTransmitMode.ptt);
|
||||||
|
expect(result.releaseTailMs, 200);
|
||||||
|
expect(result.bindKeyRequested, isTrue);
|
||||||
|
expect(result.audioConfig, config);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stores continuous mode result without key binding', () {
|
||||||
|
final config = _defaultConfig();
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.continuous,
|
||||||
|
releaseTailMs: 0,
|
||||||
|
bindKeyRequested: false,
|
||||||
|
audioConfig: config,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.mode, rust.BridgeTransmitMode.continuous);
|
||||||
|
expect(result.releaseTailMs, 0);
|
||||||
|
expect(result.bindKeyRequested, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stores voice activity mode result', () {
|
||||||
|
final config = _defaultConfig();
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.voiceActivity,
|
||||||
|
releaseTailMs: 100,
|
||||||
|
bindKeyRequested: false,
|
||||||
|
audioConfig: config,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.mode, rust.BridgeTransmitMode.voiceActivity);
|
||||||
|
expect(result.releaseTailMs, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves audio config fields', () {
|
||||||
|
final config = rust.BridgeAudioProcessingConfig(
|
||||||
|
route: rust.BridgeAudioRoute.bluetoothHfp,
|
||||||
|
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||||
|
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||||
|
vadBackend: rust.BridgeVadBackend.webrtcVad,
|
||||||
|
aec: rust.BridgeEffectOwner.platform,
|
||||||
|
ns: rust.BridgeEffectOwner.off,
|
||||||
|
agc: rust.BridgeEffectOwner.off,
|
||||||
|
hpfEnabled: false,
|
||||||
|
limiterEnabled: false,
|
||||||
|
vadHangoverMs: 300,
|
||||||
|
vadPreRollMs: 100,
|
||||||
|
vadMinTxMs: 150,
|
||||||
|
debugWavDumpEnabled: true,
|
||||||
|
);
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.ptt,
|
||||||
|
releaseTailMs: 250,
|
||||||
|
bindKeyRequested: false,
|
||||||
|
audioConfig: config,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.audioConfig.route, rust.BridgeAudioRoute.bluetoothHfp);
|
||||||
|
expect(result.audioConfig.vadBackend, rust.BridgeVadBackend.webrtcVad);
|
||||||
|
expect(result.audioConfig.ns, rust.BridgeEffectOwner.off);
|
||||||
|
expect(result.audioConfig.hpfEnabled, isFalse);
|
||||||
|
expect(result.audioConfig.debugWavDumpEnabled, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('release tail is always a round number', () {
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.ptt,
|
||||||
|
releaseTailMs: 123,
|
||||||
|
bindKeyRequested: false,
|
||||||
|
audioConfig: _defaultConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.releaseTailMs, 123);
|
||||||
|
expect(result.releaseTailMs, equals(result.releaseTailMs.round()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bindKeyRequested defaults to false for save action', () {
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.ptt,
|
||||||
|
releaseTailMs: 150,
|
||||||
|
bindKeyRequested: false,
|
||||||
|
audioConfig: _defaultConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.bindKeyRequested, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bindKeyRequested is true for bind-key action', () {
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.ptt,
|
||||||
|
releaseTailMs: 150,
|
||||||
|
bindKeyRequested: true,
|
||||||
|
audioConfig: _defaultConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.bindKeyRequested, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('VoiceSettingsDialog state management', () {
|
||||||
|
test('release tail is clamped between 0 and 500', () {
|
||||||
|
expect(999.clamp(0, 500), 500);
|
||||||
|
expect((-10).clamp(0, 500), 0);
|
||||||
|
expect(250.clamp(0, 500), 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transmit mode enum covers all three modes', () {
|
||||||
|
expect(rust.BridgeTransmitMode.values, hasLength(3));
|
||||||
|
expect(
|
||||||
|
rust.BridgeTransmitMode.values,
|
||||||
|
containsAll([
|
||||||
|
rust.BridgeTransmitMode.ptt,
|
||||||
|
rust.BridgeTransmitMode.continuous,
|
||||||
|
rust.BridgeTransmitMode.voiceActivity,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
flutter_local_notifications_windows
|
||||||
jni
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ repository.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chanora_cache = { path = "../../crates/chanora_cache" }
|
||||||
chanora_protocol = { path = "../../crates/chanora_protocol" }
|
chanora_protocol = { path = "../../crates/chanora_protocol" }
|
||||||
chanora_state = { path = "../../crates/chanora_state" }
|
chanora_state = { path = "../../crates/chanora_state" }
|
||||||
chanora_audio = { path = "../../crates/chanora_audio" }
|
chanora_audio = { path = "../../crates/chanora_audio" }
|
||||||
@@ -18,7 +19,7 @@ chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
|
|||||||
chanora_prefetch = { path = "../../crates/chanora_prefetch" }
|
chanora_prefetch = { path = "../../crates/chanora_prefetch" }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tokio = { version = "1", features = ["sync", "rt", "macros"] }
|
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Used by integration tests to inspect the bookmark DB row layout
|
# Used by integration tests to inspect the bookmark DB row layout
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# chanora_core
|
||||||
|
|
||||||
|
Top-level Rust API and orchestration layer for the Chanora client. Composes subsystem crates behind a stable, typed API consumed by `chanora_bridge`. Owns no protocol, audio, or storage logic directly.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Per SAD §7.2, `chanora_core` is the integration point:
|
||||||
|
|
||||||
|
- **`ChanoraSession`** — the primary public type. Owns at most one active server connection (DEC-006). Provides connect, disconnect, snapshot, audio lifecycle, PTT, bookmarks, and diagnostics methods.
|
||||||
|
- **Supervisor** — a per-connection tokio task that monitors connection health via a loss notifier and a watchdog probe, and auto-reconnects with exponential backoff (1 s → 60 s capped). Re-attaches the audio engine if it was running prior to the loss.
|
||||||
|
- **`SessionEvent`** — broadcast enum emitted on connect/lost/reconnecting/disconnected/audio-started/audio-stopped/voice-state/chat/route changes. Subscribers consume via `subscribe_events()`.
|
||||||
|
- **File transfer** — avatar/icon download routed through a cacache-backed blob cache with LRU eviction.
|
||||||
|
- **Channel join state machine** — reducer-based state tracking for voice channel joins, with optimistic commands, snapshot reconciliation, and error projection.
|
||||||
|
- **PTT controller** — platform input backend management, binding persistence, and release-tail timer wiring (SDD-088/094/096).
|
||||||
|
|
||||||
|
## Public API Summary
|
||||||
|
|
||||||
|
### Core types
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|---|---|
|
||||||
|
| `ChanoraSession` | Top-level session handle; cloneable, thread-safe |
|
||||||
|
| `CoreError` | Unified error enum covering all subsystem errors |
|
||||||
|
| `SessionEvent` | Broadcast lifecycle event enum |
|
||||||
|
| `ConnectConfig` | Typed connection parameters |
|
||||||
|
| `NetworkState` | OS connectivity state enum |
|
||||||
|
|
||||||
|
### Key methods on `ChanoraSession`
|
||||||
|
|
||||||
|
- `new()` — construct an empty session (no I/O)
|
||||||
|
- `init_storage(dir)` — wire identity + bookmark stores
|
||||||
|
- `init_cache(dir)` — wire the blob cache for avatars/icons
|
||||||
|
- `connect(cfg)` → `ServerSnapshot` — dial a server (single-connection invariant)
|
||||||
|
- `disconnect()` — clean teardown including supervisor
|
||||||
|
- `is_connected()` — check connection state
|
||||||
|
- `snapshot()` → `ServerSnapshot` — refresh server state
|
||||||
|
- `client_profile(client_id)` — rich profile for one client
|
||||||
|
- `voice_join(channel_id, password)` / `voice_leave()` — audio lifecycle
|
||||||
|
- `start_audio(cfg)` — initialize audio subsystem
|
||||||
|
- `set_input_device(id)` / `set_output_device(id)` — device selection
|
||||||
|
- `set_output_gain(gain)` / `set_client_volume(client_id, volume)` — volume control
|
||||||
|
- `set_transmit_mode(mode)` / `get_transmit_mode()` — transmit mode
|
||||||
|
- `set_hard_mute(muted)` — hard-mute clamp
|
||||||
|
- `set_release_tail_ms(ms)` / `get_release_tail_ms()` — release-tail config
|
||||||
|
- `set_ptt(active)` / `set_ptt_binding(binding)` / `ptt_descriptor()` — PTT control
|
||||||
|
- `send_text_message(message, target)` — chat
|
||||||
|
- `move_to_channel(id, password)` / `set_self_muted(input, output)` — channel + mute
|
||||||
|
- `subscribe_events()` — broadcast receiver for `SessionEvent`
|
||||||
|
- `drain_protocol_events()` / `protocol_events_snapshot()` — protocol event access
|
||||||
|
- `export_diagnostics()` — redacted diagnostic bundle (includes network stats)
|
||||||
|
- `audio_stats()` — audio subsystem telemetry
|
||||||
|
- `network_diagnostics_summary()` — network statistics
|
||||||
|
- `prefetch_server(host)` — warm server-address resolution
|
||||||
|
- `set_audio_processing_config(cfg)` / `get_audio_processing_config()` — audio DSP config
|
||||||
|
- `set_audio_debug_wav_dump(enabled)` — WAV dump toggle
|
||||||
|
- `set_vad_model_path(path)` — Silero model path
|
||||||
|
- `transmit_selector()` / `release_tail_timer()` — subsystem accessors
|
||||||
|
|
||||||
|
### Re-exports
|
||||||
|
|
||||||
|
Re-exports selected types from `chanora_protocol`, `chanora_audio`, `chanora_storage`, and `chanora_diagnostics` so the bridge only depends on `chanora_core`.
|
||||||
|
|
||||||
|
## Platform notes
|
||||||
|
|
||||||
|
- iOS/macOS-specific methods (`ios_handle_route_change`, `ios_handle_interruption_began`, etc.) are gated behind `cfg(target_os = "ios" | "macos")` inside method bodies.
|
||||||
|
- Android-specific reconnect paths are similarly gated.
|
||||||
|
- The crate itself compiles on all targets; platform-specific code is runtime- or cfg-gated.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- Single active connection at runtime (DEC-006)
|
||||||
|
- `tsclientlib` types never cross out of `chanora_protocol` (SAD-067)
|
||||||
|
- Secret material never lands in non-secret storage (DEC-013.2)
|
||||||
|
- Audio engine construction failure preserves the previous engine state
|
||||||
@@ -0,0 +1,801 @@
|
|||||||
|
use chanora_audio::{AudioRoute, PttBackendDescriptor};
|
||||||
|
use chanora_protocol::{MessageTarget, PokeStrength};
|
||||||
|
|
||||||
|
/// Privacy-safe snapshot of the active PTT capability.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PttDescriptorSnapshot {
|
||||||
|
/// Stable capability level name.
|
||||||
|
pub level: String,
|
||||||
|
/// Stable backend identifier.
|
||||||
|
pub backend_id: String,
|
||||||
|
/// Coarse bound input class; empty when no binding is active.
|
||||||
|
pub bound_input_class: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
|
||||||
|
fn from(desc: PttBackendDescriptor) -> Self {
|
||||||
|
Self {
|
||||||
|
level: desc.level.as_str().to_string(),
|
||||||
|
backend_id: desc.backend_id.to_string(),
|
||||||
|
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionEvent {
|
||||||
|
/// Construct a `PttCapability` event from an audio backend descriptor.
|
||||||
|
pub fn ptt_capability_from_descriptor(desc: &PttBackendDescriptor) -> Self {
|
||||||
|
Self::PttCapability {
|
||||||
|
level: desc.level.as_str().to_string(),
|
||||||
|
backend_id: desc.backend_id.to_string(),
|
||||||
|
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted PTT binding state exposed to callers.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PersistedPttBinding {
|
||||||
|
/// Stable input category string (`""`, `"keyboard"`, or
|
||||||
|
/// `"mouse-side-button"`).
|
||||||
|
pub input_class: String,
|
||||||
|
/// Display-only key label; empty when no binding is active.
|
||||||
|
pub key_label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PersistedPttBinding {
|
||||||
|
pub(crate) fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
input_class: String::new(),
|
||||||
|
key_label: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-level lifecycle event surfaced to subscribers.
|
||||||
|
///
|
||||||
|
/// This is the minimal set needed for A.6 (reconnect banner). The
|
||||||
|
/// full event catalogue lands in A.4.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SessionEvent {
|
||||||
|
/// Initial connect succeeded, or reconnect attempt succeeded.
|
||||||
|
Connected {
|
||||||
|
/// Server name reported in the snapshot.
|
||||||
|
server_name: String,
|
||||||
|
},
|
||||||
|
/// Connection lost; the supervisor will retry.
|
||||||
|
Lost {
|
||||||
|
/// Reason classification from the protocol layer.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// Supervisor is sleeping before its next reconnect attempt.
|
||||||
|
Reconnecting {
|
||||||
|
/// 1-based attempt counter for the current outage.
|
||||||
|
attempt: u32,
|
||||||
|
/// Seconds the supervisor will sleep before this attempt.
|
||||||
|
delay_secs: u32,
|
||||||
|
},
|
||||||
|
/// Supervisor gave up after `attempt` failed retries (or the
|
||||||
|
/// user explicitly disconnected mid-outage).
|
||||||
|
Disconnected {
|
||||||
|
/// Reason classification from the protocol layer.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// Audio engine started (e.g. after a successful reconnect with
|
||||||
|
/// reattachment).
|
||||||
|
AudioStarted,
|
||||||
|
/// Audio engine stopped (e.g. before a reconnect cycle, or by
|
||||||
|
/// explicit user action).
|
||||||
|
AudioStopped,
|
||||||
|
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3 /
|
||||||
|
/// DEC-023..028). Published when the audio engine starts or
|
||||||
|
/// when the active backend transitions (for example macOS
|
||||||
|
/// permission state change). Carries only the privacy-safe
|
||||||
|
/// descriptor — capability level, backend identifier, bound
|
||||||
|
/// input class — per SRS-202 / DEC-027.
|
||||||
|
PttCapability {
|
||||||
|
/// Stable level name from `PttCapabilityLevel::as_str()`.
|
||||||
|
level: String,
|
||||||
|
/// Stable backend identifier (e.g. `"focused"`).
|
||||||
|
backend_id: String,
|
||||||
|
/// Coarse bound input class (e.g. `"keyboard"`); empty when
|
||||||
|
/// no binding is active.
|
||||||
|
bound_input_class: String,
|
||||||
|
},
|
||||||
|
/// Voice subsystem state snapshot (SDD-094). Emitted on
|
||||||
|
/// `voice_join` / `voice_leave`, transmit-mode changes,
|
||||||
|
/// hard-mute toggles, and release-tail edits.
|
||||||
|
VoiceState {
|
||||||
|
/// True when the user has joined a voice channel via
|
||||||
|
/// `voice_join` and the audio engine is running.
|
||||||
|
in_channel: bool,
|
||||||
|
/// Active transmit mode encoded as
|
||||||
|
/// [`chanora_audio::TransmitMode::as_u8`].
|
||||||
|
transmit_mode: u8,
|
||||||
|
/// True when the hard-mute clamp is engaged.
|
||||||
|
mute: bool,
|
||||||
|
/// Current release-tail in milliseconds (0..=500).
|
||||||
|
release_tail_ms: u32,
|
||||||
|
/// Last confirmed authoritative channel id from the
|
||||||
|
/// `channel_join` reducer projection.
|
||||||
|
current_channel_id: Option<u64>,
|
||||||
|
/// Non-authoritative pending target channel id from the
|
||||||
|
/// reducer projection.
|
||||||
|
pending_target_channel_id: Option<u64>,
|
||||||
|
/// Whether the reducer currently allows a new join intent.
|
||||||
|
can_join: bool,
|
||||||
|
/// Whether the reducer currently allows leave intent.
|
||||||
|
can_leave: bool,
|
||||||
|
/// Join projection synchronization state.
|
||||||
|
join_sync_state: VoiceJoinSyncState,
|
||||||
|
/// Last stable sanitized join error code, if any.
|
||||||
|
join_error_code: Option<VoiceJoinErrorCode>,
|
||||||
|
},
|
||||||
|
/// iOS audio-session interruption state (SDD-101). Emitted when
|
||||||
|
/// interruption begins and when it ends (with the platform hint
|
||||||
|
/// indicating whether audio should resume).
|
||||||
|
InterruptionState {
|
||||||
|
/// True when interruption began, false when interruption ended.
|
||||||
|
began: bool,
|
||||||
|
/// Platform-provided resume hint. For begin events this is false.
|
||||||
|
should_resume: bool,
|
||||||
|
},
|
||||||
|
/// A text message was received from the server.
|
||||||
|
ChatMessage {
|
||||||
|
/// Client id of the sender.
|
||||||
|
sender_id: u64,
|
||||||
|
/// Nickname of the sender.
|
||||||
|
sender_name: String,
|
||||||
|
/// Message content.
|
||||||
|
message: String,
|
||||||
|
/// Target scope (server/channel/private/poke).
|
||||||
|
target: MessageTarget,
|
||||||
|
/// Poke notification strength, present only for poke messages.
|
||||||
|
poke_strength: Option<PokeStrength>,
|
||||||
|
},
|
||||||
|
/// Human-readable TeamSpeak-style server activity.
|
||||||
|
ServerActivity {
|
||||||
|
/// Activity line text.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
/// Audio route changed (speaker/earpiece/BT/wired headset).
|
||||||
|
AudioRouteChanged {
|
||||||
|
/// New audio output route.
|
||||||
|
route: AudioRoute,
|
||||||
|
},
|
||||||
|
/// A client moved to a different channel.
|
||||||
|
ClientMoved {
|
||||||
|
/// Unique client identifier.
|
||||||
|
client_id: u64,
|
||||||
|
/// Destination channel.
|
||||||
|
new_channel_id: u64,
|
||||||
|
},
|
||||||
|
/// A new client connected.
|
||||||
|
ClientJoined {
|
||||||
|
/// Unique client identifier.
|
||||||
|
client_id: u64,
|
||||||
|
/// Channel the client joined.
|
||||||
|
channel_id: u64,
|
||||||
|
/// Display nickname.
|
||||||
|
name: String,
|
||||||
|
/// Microphone muted state.
|
||||||
|
input_muted: bool,
|
||||||
|
/// Speaker muted state.
|
||||||
|
output_muted: bool,
|
||||||
|
/// Whether this is a server query (bot) client.
|
||||||
|
is_server_query: bool,
|
||||||
|
/// Client's talk power value.
|
||||||
|
talk_power: i32,
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
|
talk_power_granted: bool,
|
||||||
|
},
|
||||||
|
/// A client disconnected.
|
||||||
|
ClientLeft {
|
||||||
|
/// Unique client identifier.
|
||||||
|
client_id: u64,
|
||||||
|
/// Display nickname at time of disconnect.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Client properties changed.
|
||||||
|
ClientUpdated {
|
||||||
|
/// Unique client identifier.
|
||||||
|
client_id: u64,
|
||||||
|
/// Microphone muted state.
|
||||||
|
input_muted: bool,
|
||||||
|
/// Speaker muted state.
|
||||||
|
output_muted: bool,
|
||||||
|
/// Whether this is a server query (bot) client.
|
||||||
|
is_server_query: bool,
|
||||||
|
/// Client's talk power value.
|
||||||
|
talk_power: i32,
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
|
talk_power_granted: bool,
|
||||||
|
},
|
||||||
|
/// A new channel appeared.
|
||||||
|
ChannelAdded {
|
||||||
|
/// Unique channel identifier.
|
||||||
|
id: u64,
|
||||||
|
/// Parent channel ID.
|
||||||
|
parent: u64,
|
||||||
|
/// Channel name.
|
||||||
|
name: String,
|
||||||
|
/// Predecessor channel ID within the same parent (TeamSpeak
|
||||||
|
/// linked-list ordering hint). Zero means first child.
|
||||||
|
order: i64,
|
||||||
|
/// Whether the channel requires a password.
|
||||||
|
has_password: bool,
|
||||||
|
/// Talk power required to speak, or `None` when unrestricted.
|
||||||
|
needed_talk_power: Option<i32>,
|
||||||
|
},
|
||||||
|
/// A channel was deleted.
|
||||||
|
ChannelRemoved {
|
||||||
|
/// Channel identifier.
|
||||||
|
id: u64,
|
||||||
|
},
|
||||||
|
/// Channel properties changed.
|
||||||
|
ChannelUpdated {
|
||||||
|
/// Unique channel identifier.
|
||||||
|
id: u64,
|
||||||
|
/// Channel name.
|
||||||
|
name: String,
|
||||||
|
/// Whether the channel requires a password.
|
||||||
|
has_password: bool,
|
||||||
|
/// Talk power required to speak, or `None` when unrestricted.
|
||||||
|
needed_talk_power: Option<i32>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge-safe mirror of channel-join projection sync state.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum VoiceJoinSyncState {
|
||||||
|
/// Reducer is ready to accept channel actions.
|
||||||
|
Ready,
|
||||||
|
/// Reducer is synchronizing against an initial snapshot.
|
||||||
|
SynchronizingInitialSnapshot,
|
||||||
|
/// Reducer is synchronizing after reconnect.
|
||||||
|
SynchronizingReconnect,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge-safe mirror of stable channel-join error codes.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum VoiceJoinErrorCode {
|
||||||
|
/// Duplicate same-target join intent was coalesced.
|
||||||
|
DuplicateSameTargetCoalesced,
|
||||||
|
/// A different target was requested while one is already pending.
|
||||||
|
JoinAlreadyPendingDifferentTarget,
|
||||||
|
/// Join denied by server policy/permission.
|
||||||
|
JoinDenied,
|
||||||
|
/// Join failed due to protocol-level error.
|
||||||
|
JoinProtocolFailure,
|
||||||
|
/// Join failed due to transport/network error.
|
||||||
|
JoinNetworkFailure,
|
||||||
|
/// Join timed out awaiting confirmation.
|
||||||
|
JoinTimeout,
|
||||||
|
/// Pending join was superseded by user leave.
|
||||||
|
JoinSupersededByLeave,
|
||||||
|
/// Stale join outcome was ignored.
|
||||||
|
JoinStaleOutcomeIgnored,
|
||||||
|
/// Authoritative membership reconciled to different channel.
|
||||||
|
JoinReconciledDifferentChannel,
|
||||||
|
/// Join command was rejected before send acceptance.
|
||||||
|
JoinCommandRejectedBeforeSend,
|
||||||
|
/// Join intent rejected while reducer synchronizing.
|
||||||
|
JoinCannotStartWhileSynchronizing,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coarse OS-reported network state. Populated by the Flutter side
|
||||||
|
/// via `connectivity_plus`; on platforms where no signal is wired
|
||||||
|
/// we stay at `Unknown` forever and the supervisor falls back to
|
||||||
|
/// pure watchdog/backoff behaviour.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum NetworkState {
|
||||||
|
/// No signal seen yet — treat as ambiguous; don't change behaviour.
|
||||||
|
Unknown,
|
||||||
|
/// OS reports at least one network with internet capability.
|
||||||
|
Online,
|
||||||
|
/// OS reports no networks available.
|
||||||
|
Offline,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn network_state_equality() {
|
||||||
|
assert_eq!(NetworkState::Unknown, NetworkState::Unknown);
|
||||||
|
assert_eq!(NetworkState::Online, NetworkState::Online);
|
||||||
|
assert_eq!(NetworkState::Offline, NetworkState::Offline);
|
||||||
|
assert_ne!(NetworkState::Unknown, NetworkState::Online);
|
||||||
|
assert_ne!(NetworkState::Online, NetworkState::Offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_ptt_binding_empty() {
|
||||||
|
let binding = PersistedPttBinding::empty();
|
||||||
|
assert_eq!(binding.input_class, "");
|
||||||
|
assert_eq!(binding.key_label, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_ptt_binding_equality() {
|
||||||
|
let a = PersistedPttBinding {
|
||||||
|
input_class: "keyboard".to_string(),
|
||||||
|
key_label: "Space".to_string(),
|
||||||
|
};
|
||||||
|
let b = PersistedPttBinding {
|
||||||
|
input_class: "keyboard".to_string(),
|
||||||
|
key_label: "Space".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ptt_descriptor_snapshot_fields() {
|
||||||
|
let snap = PttDescriptorSnapshot {
|
||||||
|
level: "L0Focused".to_string(),
|
||||||
|
backend_id: "focused".to_string(),
|
||||||
|
bound_input_class: "keyboard".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(snap.level, "L0Focused");
|
||||||
|
assert_eq!(snap.backend_id, "focused");
|
||||||
|
assert_eq!(snap.bound_input_class, "keyboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_connected() {
|
||||||
|
let evt = SessionEvent::Connected {
|
||||||
|
server_name: "Test Server".to_string(),
|
||||||
|
};
|
||||||
|
if let SessionEvent::Connected { server_name } = evt {
|
||||||
|
assert_eq!(server_name, "Test Server");
|
||||||
|
} else {
|
||||||
|
panic!("expected Connected variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_lost() {
|
||||||
|
let evt = SessionEvent::Lost {
|
||||||
|
reason: "timeout".to_string(),
|
||||||
|
};
|
||||||
|
if let SessionEvent::Lost { reason } = evt {
|
||||||
|
assert_eq!(reason, "timeout");
|
||||||
|
} else {
|
||||||
|
panic!("expected Lost variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_reconnecting() {
|
||||||
|
let evt = SessionEvent::Reconnecting {
|
||||||
|
attempt: 3,
|
||||||
|
delay_secs: 30,
|
||||||
|
};
|
||||||
|
if let SessionEvent::Reconnecting {
|
||||||
|
attempt,
|
||||||
|
delay_secs,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(attempt, 3);
|
||||||
|
assert_eq!(delay_secs, 30);
|
||||||
|
} else {
|
||||||
|
panic!("expected Reconnecting variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_disconnected() {
|
||||||
|
let evt = SessionEvent::Disconnected {
|
||||||
|
reason: "user".to_string(),
|
||||||
|
};
|
||||||
|
if let SessionEvent::Disconnected { reason } = evt {
|
||||||
|
assert_eq!(reason, "user");
|
||||||
|
} else {
|
||||||
|
panic!("expected Disconnected variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_audio_started_stopped() {
|
||||||
|
let _ = SessionEvent::AudioStarted;
|
||||||
|
let _ = SessionEvent::AudioStopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_ptt_capability() {
|
||||||
|
let evt = SessionEvent::PttCapability {
|
||||||
|
level: "L1GlobalShortcut".to_string(),
|
||||||
|
backend_id: "global".to_string(),
|
||||||
|
bound_input_class: "keyboard".to_string(),
|
||||||
|
};
|
||||||
|
if let SessionEvent::PttCapability {
|
||||||
|
level,
|
||||||
|
backend_id,
|
||||||
|
bound_input_class,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(level, "L1GlobalShortcut");
|
||||||
|
assert_eq!(backend_id, "global");
|
||||||
|
assert_eq!(bound_input_class, "keyboard");
|
||||||
|
} else {
|
||||||
|
panic!("expected PttCapability variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_voice_state() {
|
||||||
|
let evt = SessionEvent::VoiceState {
|
||||||
|
in_channel: true,
|
||||||
|
transmit_mode: 1,
|
||||||
|
mute: false,
|
||||||
|
release_tail_ms: 200,
|
||||||
|
current_channel_id: Some(42),
|
||||||
|
pending_target_channel_id: None,
|
||||||
|
can_join: false,
|
||||||
|
can_leave: true,
|
||||||
|
join_sync_state: VoiceJoinSyncState::Ready,
|
||||||
|
join_error_code: None,
|
||||||
|
};
|
||||||
|
if let SessionEvent::VoiceState {
|
||||||
|
in_channel,
|
||||||
|
transmit_mode,
|
||||||
|
mute,
|
||||||
|
release_tail_ms,
|
||||||
|
current_channel_id,
|
||||||
|
pending_target_channel_id,
|
||||||
|
can_join,
|
||||||
|
can_leave,
|
||||||
|
join_sync_state,
|
||||||
|
join_error_code,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert!(in_channel);
|
||||||
|
assert_eq!(transmit_mode, 1);
|
||||||
|
assert!(!mute);
|
||||||
|
assert_eq!(release_tail_ms, 200);
|
||||||
|
assert_eq!(current_channel_id, Some(42));
|
||||||
|
assert_eq!(pending_target_channel_id, None);
|
||||||
|
assert!(!can_join);
|
||||||
|
assert!(can_leave);
|
||||||
|
assert_eq!(join_sync_state, VoiceJoinSyncState::Ready);
|
||||||
|
assert!(join_error_code.is_none());
|
||||||
|
} else {
|
||||||
|
panic!("expected VoiceState variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_interruption_state() {
|
||||||
|
let evt = SessionEvent::InterruptionState {
|
||||||
|
began: true,
|
||||||
|
should_resume: false,
|
||||||
|
};
|
||||||
|
if let SessionEvent::InterruptionState {
|
||||||
|
began,
|
||||||
|
should_resume,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert!(began);
|
||||||
|
assert!(!should_resume);
|
||||||
|
} else {
|
||||||
|
panic!("expected InterruptionState variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_chat_message() {
|
||||||
|
let evt = SessionEvent::ChatMessage {
|
||||||
|
sender_id: 5,
|
||||||
|
sender_name: "Alice".to_string(),
|
||||||
|
message: "Hello".to_string(),
|
||||||
|
target: chanora_protocol::MessageTarget::Channel,
|
||||||
|
poke_strength: None,
|
||||||
|
};
|
||||||
|
if let SessionEvent::ChatMessage {
|
||||||
|
sender_id,
|
||||||
|
sender_name,
|
||||||
|
message,
|
||||||
|
target,
|
||||||
|
poke_strength,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(sender_id, 5);
|
||||||
|
assert_eq!(sender_name, "Alice");
|
||||||
|
assert_eq!(message, "Hello");
|
||||||
|
assert_eq!(target, chanora_protocol::MessageTarget::Channel);
|
||||||
|
assert!(poke_strength.is_none());
|
||||||
|
} else {
|
||||||
|
panic!("expected ChatMessage variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_chat_message_with_poke() {
|
||||||
|
let evt = SessionEvent::ChatMessage {
|
||||||
|
sender_id: 3,
|
||||||
|
sender_name: "Bob".to_string(),
|
||||||
|
message: "".to_string(),
|
||||||
|
target: chanora_protocol::MessageTarget::Poke(7),
|
||||||
|
poke_strength: Some(chanora_protocol::PokeStrength::Suppressed),
|
||||||
|
};
|
||||||
|
if let SessionEvent::ChatMessage {
|
||||||
|
target,
|
||||||
|
poke_strength,
|
||||||
|
..
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(target, chanora_protocol::MessageTarget::Poke(7));
|
||||||
|
assert_eq!(poke_strength, Some(chanora_protocol::PokeStrength::Suppressed));
|
||||||
|
} else {
|
||||||
|
panic!("expected ChatMessage variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_server_activity() {
|
||||||
|
let evt = SessionEvent::ServerActivity {
|
||||||
|
message: "User joined channel".to_string(),
|
||||||
|
};
|
||||||
|
if let SessionEvent::ServerActivity { message } = &evt {
|
||||||
|
assert_eq!(message, "User joined channel");
|
||||||
|
} else {
|
||||||
|
panic!("expected ServerActivity variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_audio_route_changed() {
|
||||||
|
let evt = SessionEvent::AudioRouteChanged {
|
||||||
|
route: chanora_audio::AudioRoute::Speaker,
|
||||||
|
};
|
||||||
|
if let SessionEvent::AudioRouteChanged { route } = &evt {
|
||||||
|
assert_eq!(*route, chanora_audio::AudioRoute::Speaker);
|
||||||
|
} else {
|
||||||
|
panic!("expected AudioRouteChanged variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_client_moved() {
|
||||||
|
let evt = SessionEvent::ClientMoved {
|
||||||
|
client_id: 1,
|
||||||
|
new_channel_id: 2,
|
||||||
|
};
|
||||||
|
if let SessionEvent::ClientMoved {
|
||||||
|
client_id,
|
||||||
|
new_channel_id,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(client_id, 1);
|
||||||
|
assert_eq!(new_channel_id, 2);
|
||||||
|
} else {
|
||||||
|
panic!("expected ClientMoved variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_client_joined() {
|
||||||
|
let evt = SessionEvent::ClientJoined {
|
||||||
|
client_id: 10,
|
||||||
|
channel_id: 3,
|
||||||
|
name: "NewUser".to_string(),
|
||||||
|
input_muted: false,
|
||||||
|
output_muted: true,
|
||||||
|
is_server_query: false,
|
||||||
|
talk_power: 0,
|
||||||
|
talk_power_granted: false,
|
||||||
|
};
|
||||||
|
if let SessionEvent::ClientJoined {
|
||||||
|
client_id,
|
||||||
|
channel_id,
|
||||||
|
name,
|
||||||
|
input_muted,
|
||||||
|
output_muted,
|
||||||
|
is_server_query,
|
||||||
|
talk_power,
|
||||||
|
talk_power_granted,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(client_id, 10);
|
||||||
|
assert_eq!(channel_id, 3);
|
||||||
|
assert_eq!(name, "NewUser");
|
||||||
|
assert!(!input_muted);
|
||||||
|
assert!(output_muted);
|
||||||
|
assert!(!is_server_query);
|
||||||
|
assert_eq!(talk_power, 0);
|
||||||
|
assert!(!talk_power_granted);
|
||||||
|
} else {
|
||||||
|
panic!("expected ClientJoined variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_client_left() {
|
||||||
|
let evt = SessionEvent::ClientLeft {
|
||||||
|
client_id: 10,
|
||||||
|
name: "Departing".to_string(),
|
||||||
|
};
|
||||||
|
if let SessionEvent::ClientLeft { client_id, name } = evt {
|
||||||
|
assert_eq!(client_id, 10);
|
||||||
|
assert_eq!(name, "Departing");
|
||||||
|
} else {
|
||||||
|
panic!("expected ClientLeft variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_client_updated() {
|
||||||
|
let evt = SessionEvent::ClientUpdated {
|
||||||
|
client_id: 5,
|
||||||
|
input_muted: true,
|
||||||
|
output_muted: false,
|
||||||
|
is_server_query: true,
|
||||||
|
talk_power: 75,
|
||||||
|
talk_power_granted: true,
|
||||||
|
};
|
||||||
|
if let SessionEvent::ClientUpdated {
|
||||||
|
client_id,
|
||||||
|
input_muted,
|
||||||
|
output_muted,
|
||||||
|
is_server_query,
|
||||||
|
talk_power,
|
||||||
|
talk_power_granted,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(client_id, 5);
|
||||||
|
assert!(input_muted);
|
||||||
|
assert!(!output_muted);
|
||||||
|
assert!(is_server_query);
|
||||||
|
assert_eq!(talk_power, 75);
|
||||||
|
assert!(talk_power_granted);
|
||||||
|
} else {
|
||||||
|
panic!("expected ClientUpdated variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_channel_added() {
|
||||||
|
let evt = SessionEvent::ChannelAdded {
|
||||||
|
id: 7,
|
||||||
|
parent: 1,
|
||||||
|
name: "Sub".to_string(),
|
||||||
|
order: 3,
|
||||||
|
has_password: true,
|
||||||
|
needed_talk_power: Some(50),
|
||||||
|
};
|
||||||
|
if let SessionEvent::ChannelAdded {
|
||||||
|
id,
|
||||||
|
parent,
|
||||||
|
name,
|
||||||
|
order,
|
||||||
|
has_password,
|
||||||
|
needed_talk_power,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(id, 7);
|
||||||
|
assert_eq!(parent, 1);
|
||||||
|
assert_eq!(name, "Sub");
|
||||||
|
assert_eq!(order, 3);
|
||||||
|
assert!(has_password);
|
||||||
|
assert_eq!(needed_talk_power, Some(50));
|
||||||
|
} else {
|
||||||
|
panic!("expected ChannelAdded variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_channel_removed() {
|
||||||
|
let evt = SessionEvent::ChannelRemoved { id: 7 };
|
||||||
|
if let SessionEvent::ChannelRemoved { id } = evt {
|
||||||
|
assert_eq!(id, 7);
|
||||||
|
} else {
|
||||||
|
panic!("expected ChannelRemoved variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_channel_updated() {
|
||||||
|
let evt = SessionEvent::ChannelUpdated {
|
||||||
|
id: 7,
|
||||||
|
name: "Renamed".to_string(),
|
||||||
|
has_password: false,
|
||||||
|
needed_talk_power: None,
|
||||||
|
};
|
||||||
|
if let SessionEvent::ChannelUpdated {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
has_password,
|
||||||
|
needed_talk_power,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(id, 7);
|
||||||
|
assert_eq!(name, "Renamed");
|
||||||
|
assert!(!has_password);
|
||||||
|
assert!(needed_talk_power.is_none());
|
||||||
|
} else {
|
||||||
|
panic!("expected ChannelUpdated variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn voice_join_sync_state_variants() {
|
||||||
|
let ready = VoiceJoinSyncState::Ready;
|
||||||
|
let init = VoiceJoinSyncState::SynchronizingInitialSnapshot;
|
||||||
|
let reconnect = VoiceJoinSyncState::SynchronizingReconnect;
|
||||||
|
assert_ne!(
|
||||||
|
std::mem::discriminant(&ready),
|
||||||
|
std::mem::discriminant(&init)
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
std::mem::discriminant(&init),
|
||||||
|
std::mem::discriminant(&reconnect)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn voice_join_error_code_all_variants() {
|
||||||
|
let codes = [
|
||||||
|
VoiceJoinErrorCode::DuplicateSameTargetCoalesced,
|
||||||
|
VoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget,
|
||||||
|
VoiceJoinErrorCode::JoinDenied,
|
||||||
|
VoiceJoinErrorCode::JoinProtocolFailure,
|
||||||
|
VoiceJoinErrorCode::JoinNetworkFailure,
|
||||||
|
VoiceJoinErrorCode::JoinTimeout,
|
||||||
|
VoiceJoinErrorCode::JoinSupersededByLeave,
|
||||||
|
VoiceJoinErrorCode::JoinStaleOutcomeIgnored,
|
||||||
|
VoiceJoinErrorCode::JoinReconciledDifferentChannel,
|
||||||
|
VoiceJoinErrorCode::JoinCommandRejectedBeforeSend,
|
||||||
|
VoiceJoinErrorCode::JoinCannotStartWhileSynchronizing,
|
||||||
|
];
|
||||||
|
for i in 0..codes.len() {
|
||||||
|
for j in 0..codes.len() {
|
||||||
|
if i == j {
|
||||||
|
assert_eq!(
|
||||||
|
std::mem::discriminant(&codes[i]),
|
||||||
|
std::mem::discriminant(&codes[j])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_ne!(
|
||||||
|
std::mem::discriminant(&codes[i]),
|
||||||
|
std::mem::discriminant(&codes[j])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_clone_preserves_fields() {
|
||||||
|
let evt = SessionEvent::Connected {
|
||||||
|
server_name: "Cloneable".to_string(),
|
||||||
|
};
|
||||||
|
let cloned = evt.clone();
|
||||||
|
if let SessionEvent::Connected { server_name } = cloned {
|
||||||
|
assert_eq!(server_name, "Cloneable");
|
||||||
|
} else {
|
||||||
|
panic!("expected Connected variant after clone");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_voice_state_with_join_error() {
|
||||||
|
let evt = SessionEvent::VoiceState {
|
||||||
|
in_channel: false,
|
||||||
|
transmit_mode: 0,
|
||||||
|
mute: false,
|
||||||
|
release_tail_ms: 200,
|
||||||
|
current_channel_id: None,
|
||||||
|
pending_target_channel_id: None,
|
||||||
|
can_join: true,
|
||||||
|
can_leave: false,
|
||||||
|
join_sync_state: VoiceJoinSyncState::Ready,
|
||||||
|
join_error_code: Some(VoiceJoinErrorCode::JoinDenied),
|
||||||
|
};
|
||||||
|
if let SessionEvent::VoiceState { join_error_code, .. } = evt {
|
||||||
|
assert_eq!(join_error_code, Some(VoiceJoinErrorCode::JoinDenied));
|
||||||
|
} else {
|
||||||
|
panic!("expected VoiceState variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use chanora_cache::{BlobCache, BlobCacheError, PREFIX_AVATAR, PREFIX_ICON};
|
||||||
|
use chanora_protocol::{ProtocolClient, ProtocolError};
|
||||||
|
use tokio::sync::{Mutex, Semaphore, oneshot};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
const MAX_CONCURRENT_DOWNLOADS: usize = 2;
|
||||||
|
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
|
||||||
|
|
||||||
|
type InFlightWaiters = Vec<oneshot::Sender<Result<Option<Vec<u8>>, FileTransferError>>>;
|
||||||
|
|
||||||
|
/// Errors raised while resolving protocol-owned file assets.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum FileTransferError {
|
||||||
|
/// No live protocol client is available for a download.
|
||||||
|
#[error("not connected")]
|
||||||
|
NotConnected,
|
||||||
|
/// The protocol layer failed while downloading the asset.
|
||||||
|
#[error("protocol error: {0}")]
|
||||||
|
Protocol(#[from] ProtocolError),
|
||||||
|
/// The blob cache failed while reading or writing the asset.
|
||||||
|
#[error("cache error: {0}")]
|
||||||
|
Cache(#[from] BlobCacheError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for FileTransferError {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::NotConnected => Self::NotConnected,
|
||||||
|
Self::Protocol(error) => Self::Protocol(clone_protocol_error(error)),
|
||||||
|
Self::Cache(error) => Self::Cache(clone_blob_cache_error(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FileTransferService {
|
||||||
|
cache: BlobCache,
|
||||||
|
protocol: Arc<Mutex<Option<ProtocolClient>>>,
|
||||||
|
semaphore: Arc<Semaphore>,
|
||||||
|
in_flight: Arc<Mutex<HashMap<String, InFlightWaiters>>>,
|
||||||
|
negative_cache: Arc<Mutex<HashMap<String, Instant>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileTransferService {
|
||||||
|
pub fn new(cache: BlobCache, protocol: Arc<Mutex<Option<ProtocolClient>>>) -> Self {
|
||||||
|
Self {
|
||||||
|
cache,
|
||||||
|
protocol,
|
||||||
|
semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_DOWNLOADS)),
|
||||||
|
in_flight: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
negative_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_protocol(&self, client: Option<ProtocolClient>) {
|
||||||
|
*self.protocol.lock().await = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_avatar(
|
||||||
|
&self,
|
||||||
|
avatar_hash: &str,
|
||||||
|
client_uid: &str,
|
||||||
|
) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||||
|
if let Some(bytes) = self.cache.get(PREFIX_AVATAR, avatar_hash).await? {
|
||||||
|
return Ok(Some(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_negative_cache_hit(avatar_hash).await {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rx = {
|
||||||
|
let mut in_flight = self.in_flight.lock().await;
|
||||||
|
if let Some(waiters) = in_flight.get_mut(avatar_hash) {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
waiters.push(tx);
|
||||||
|
Some(rx)
|
||||||
|
} else {
|
||||||
|
in_flight.insert(avatar_hash.to_string(), Vec::new());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(rx) = rx {
|
||||||
|
return rx.await.unwrap_or_else(|_| {
|
||||||
|
Err(FileTransferError::Protocol(ProtocolError::Lost(
|
||||||
|
"coalesced avatar download waiter dropped".to_string(),
|
||||||
|
)))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let _permit = self
|
||||||
|
.semaphore
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.expect("file transfer semaphore should stay open");
|
||||||
|
|
||||||
|
let result = self.do_download_avatar(avatar_hash, client_uid).await;
|
||||||
|
self.finish_in_flight(avatar_hash, &result).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_icon(&self, icon_id: u64) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||||
|
let icon_key = icon_id.to_string();
|
||||||
|
let negative_key = format!("ic_{icon_id}");
|
||||||
|
let in_flight_key = format!("icon_{icon_id}");
|
||||||
|
|
||||||
|
if let Some(bytes) = self.cache.get(PREFIX_ICON, &icon_key).await? {
|
||||||
|
return Ok(Some(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_negative_cache_hit(&negative_key).await {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rx = {
|
||||||
|
let mut in_flight = self.in_flight.lock().await;
|
||||||
|
if let Some(waiters) = in_flight.get_mut(&in_flight_key) {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
waiters.push(tx);
|
||||||
|
Some(rx)
|
||||||
|
} else {
|
||||||
|
in_flight.insert(in_flight_key.clone(), Vec::new());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(rx) = rx {
|
||||||
|
return rx.await.unwrap_or_else(|_| {
|
||||||
|
Err(FileTransferError::Protocol(ProtocolError::Lost(
|
||||||
|
"coalesced icon download waiter dropped".to_string(),
|
||||||
|
)))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let _permit = self
|
||||||
|
.semaphore
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.expect("file transfer semaphore should stay open");
|
||||||
|
|
||||||
|
let result = self.do_download_icon(icon_id).await;
|
||||||
|
self.finish_in_flight(&in_flight_key, &result).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_cache(&self) -> Result<(), FileTransferError> {
|
||||||
|
self.cache.clear().await?;
|
||||||
|
self.negative_cache.lock().await.clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cache_size(&self) -> Result<u64, FileTransferError> {
|
||||||
|
Ok(self.cache.total_size().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_download_avatar(
|
||||||
|
&self,
|
||||||
|
avatar_hash: &str,
|
||||||
|
client_uid: &str,
|
||||||
|
) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||||
|
if let Some(bytes) = self.cache.get(PREFIX_AVATAR, avatar_hash).await? {
|
||||||
|
return Ok(Some(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_negative_cache_hit(avatar_hash).await {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let protocol = self.protocol.lock().await;
|
||||||
|
let client = protocol.as_ref().ok_or(FileTransferError::NotConnected)?;
|
||||||
|
match client.download_avatar(client_uid).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
self.cache.put(PREFIX_AVATAR, avatar_hash, &bytes).await?;
|
||||||
|
self.negative_cache.lock().await.remove(avatar_hash);
|
||||||
|
Ok(Some(bytes))
|
||||||
|
}
|
||||||
|
Err(ProtocolError::ServerRejected { .. }) => {
|
||||||
|
self.negative_cache
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(avatar_hash.to_string(), Instant::now() + NEGATIVE_CACHE_TTL);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Err(error) => Err(FileTransferError::Protocol(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_download_icon(&self, icon_id: u64) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||||
|
let icon_key = icon_id.to_string();
|
||||||
|
let negative_key = format!("ic_{icon_id}");
|
||||||
|
|
||||||
|
if let Some(bytes) = self.cache.get(PREFIX_ICON, &icon_key).await? {
|
||||||
|
return Ok(Some(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_negative_cache_hit(&negative_key).await {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let protocol = self.protocol.lock().await;
|
||||||
|
let client = protocol.as_ref().ok_or(FileTransferError::NotConnected)?;
|
||||||
|
match client.download_icon(icon_id).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
self.cache.put(PREFIX_ICON, &icon_key, &bytes).await?;
|
||||||
|
self.negative_cache.lock().await.remove(&negative_key);
|
||||||
|
Ok(Some(bytes))
|
||||||
|
}
|
||||||
|
Err(ProtocolError::ServerRejected { .. }) => {
|
||||||
|
self.negative_cache
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(negative_key, Instant::now() + NEGATIVE_CACHE_TTL);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Err(error) => Err(FileTransferError::Protocol(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish_in_flight(
|
||||||
|
&self,
|
||||||
|
avatar_hash: &str,
|
||||||
|
result: &Result<Option<Vec<u8>>, FileTransferError>,
|
||||||
|
) {
|
||||||
|
let waiters = self.in_flight.lock().await.remove(avatar_hash).unwrap_or_default();
|
||||||
|
for waiter in waiters {
|
||||||
|
if waiter.send(result.clone()).is_err() {
|
||||||
|
warn!(target: "chanora_core", avatar_hash, "avatar download waiter dropped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_negative_cache_hit(&self, avatar_hash: &str) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut negative_cache = self.negative_cache.lock().await;
|
||||||
|
match negative_cache.get(avatar_hash).copied() {
|
||||||
|
Some(expires_at) if expires_at > now => true,
|
||||||
|
Some(_) => {
|
||||||
|
negative_cache.remove(avatar_hash);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_protocol_error(error: &ProtocolError) -> ProtocolError {
|
||||||
|
match error {
|
||||||
|
ProtocolError::Invalid(message) => ProtocolError::Invalid(message.clone()),
|
||||||
|
ProtocolError::DnsFailed { host, reason } => ProtocolError::DnsFailed {
|
||||||
|
host: host.clone(),
|
||||||
|
reason: reason.clone(),
|
||||||
|
},
|
||||||
|
ProtocolError::Connect(message) => ProtocolError::Connect(message.clone()),
|
||||||
|
ProtocolError::DisconnectedEarly(message) => {
|
||||||
|
ProtocolError::DisconnectedEarly(message.clone())
|
||||||
|
}
|
||||||
|
ProtocolError::Lost(message) => ProtocolError::Lost(message.clone()),
|
||||||
|
ProtocolError::Identity(message) => ProtocolError::Identity(message.clone()),
|
||||||
|
ProtocolError::Timeout => ProtocolError::Timeout,
|
||||||
|
ProtocolError::ServerRejected { code, message } => ProtocolError::ServerRejected {
|
||||||
|
code: *code,
|
||||||
|
message: message.clone(),
|
||||||
|
},
|
||||||
|
ProtocolError::Backend(message) => ProtocolError::Backend(message.clone()),
|
||||||
|
ProtocolError::FileTransfer(message) => ProtocolError::FileTransfer(message.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_blob_cache_error(error: &BlobCacheError) -> BlobCacheError {
|
||||||
|
match error {
|
||||||
|
BlobCacheError::Io(message) => BlobCacheError::Io(message.clone()),
|
||||||
|
BlobCacheError::InvalidKey(message) => BlobCacheError::InvalidKey(message.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_cache_dir(name: &str) -> std::path::PathBuf {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
path.push(format!("chanora-core-file-transfer-{name}-{nanos}"));
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn returns_cached_avatar_without_connection() {
|
||||||
|
let cache_dir = test_cache_dir("cache-hit");
|
||||||
|
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
|
||||||
|
cache.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"avatar")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
|
||||||
|
|
||||||
|
let avatar = service
|
||||||
|
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(avatar, Some(b"avatar".to_vec()));
|
||||||
|
let _ = std::fs::remove_dir_all(cache_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn negative_cache_short_circuits_not_connected() {
|
||||||
|
let cache_dir = test_cache_dir("negative-cache");
|
||||||
|
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
|
||||||
|
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
|
||||||
|
service
|
||||||
|
.negative_cache
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6".to_string(), Instant::now() + NEGATIVE_CACHE_TTL);
|
||||||
|
|
||||||
|
let avatar = service
|
||||||
|
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(avatar, None);
|
||||||
|
let _ = std::fs::remove_dir_all(cache_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn returns_cached_icon_without_connection() {
|
||||||
|
let cache_dir = test_cache_dir("icon-cache-hit");
|
||||||
|
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
|
||||||
|
cache.put(PREFIX_ICON, "12345", b"icon").await.unwrap();
|
||||||
|
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
|
||||||
|
|
||||||
|
let icon = service.get_icon(12345).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(icon, Some(b"icon".to_vec()));
|
||||||
|
let _ = std::fs::remove_dir_all(cache_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
+361
-407
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
/// Network diagnostics snapshot collected across connection lifetimes.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub(crate) struct NetworkDiagnostics {
|
||||||
|
/// Total count of connects (including the initial one).
|
||||||
|
connect_count: u64,
|
||||||
|
/// Count of disconnects (graceful + loss).
|
||||||
|
disconnect_count: u64,
|
||||||
|
/// Recent loss reasons (last 8, ring buffer).
|
||||||
|
loss_reasons: VecDeque<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkDiagnostics {
|
||||||
|
pub(crate) fn record_connect(&mut self) {
|
||||||
|
self.connect_count = self.connect_count.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_loss(&mut self, reason: &str) {
|
||||||
|
self.disconnect_count = self.disconnect_count.saturating_add(1);
|
||||||
|
if self.loss_reasons.len() >= 8 {
|
||||||
|
self.loss_reasons.pop_front();
|
||||||
|
}
|
||||||
|
self.loss_reasons.push_back(reason.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn summary(&self) -> String {
|
||||||
|
let mut s = format!(
|
||||||
|
"connects: {}\ndisconnects: {}\n",
|
||||||
|
self.connect_count, self.disconnect_count
|
||||||
|
);
|
||||||
|
if !self.loss_reasons.is_empty() {
|
||||||
|
s.push_str(&format!(
|
||||||
|
"loss_reasons: [{}]\n",
|
||||||
|
self.loss_reasons
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn network_diagnostics_keeps_last_eight_loss_reasons() {
|
||||||
|
let mut diagnostics = NetworkDiagnostics::default();
|
||||||
|
|
||||||
|
for i in 0..10 {
|
||||||
|
diagnostics.record_loss(&format!("loss-{i}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(diagnostics.disconnect_count, 10);
|
||||||
|
assert_eq!(diagnostics.loss_reasons.len(), 8);
|
||||||
|
assert_eq!(
|
||||||
|
diagnostics.loss_reasons.front().map(String::as_str),
|
||||||
|
Some("loss-2")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
diagnostics.loss_reasons.back().map(String::as_str),
|
||||||
|
Some("loss-9")
|
||||||
|
);
|
||||||
|
assert!(diagnostics.summary().contains(
|
||||||
|
"loss_reasons: [loss-2, loss-3, loss-4, loss-5, loss-6, loss-7, loss-8, loss-9]"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_avatar_returns_cached_bytes_without_connection() {
|
||||||
|
let tmp = mktemp("chanora_core_avatar_cache_test");
|
||||||
|
let session = chanora_core::ChanoraSession::new();
|
||||||
|
session.init_cache(tmp.to_str().unwrap()).await.unwrap();
|
||||||
|
|
||||||
|
let cache = chanora_cache::BlobCache::new(&tmp, 100 * 1024 * 1024).unwrap();
|
||||||
|
let hash = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
|
||||||
|
cache
|
||||||
|
.put(chanora_cache::PREFIX_AVATAR, hash, b"avatar-bytes")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let bytes = session
|
||||||
|
.get_avatar(hash, "client-uid")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bytes, b"avatar-bytes");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_avatar_without_cache_or_connection_returns_not_connected() {
|
||||||
|
let session = chanora_core::ChanoraSession::new();
|
||||||
|
let err = session
|
||||||
|
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client-uid")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, chanora_core::CoreError::NotConnected));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mktemp(label: &str) -> PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let p = env::temp_dir()
|
||||||
|
.join(label)
|
||||||
|
.join(format!("{}-{nanos}", process::id()));
|
||||||
|
std::fs::create_dir_all(&p).unwrap();
|
||||||
|
p
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ coreaudio-rs = "0.14"
|
|||||||
# on the main queue to avoid the VPIO RPC timeout on iOS simulator.
|
# on the main queue to avoid the VPIO RPC timeout on iOS simulator.
|
||||||
dispatch2 = "0.3"
|
dispatch2 = "0.3"
|
||||||
|
|
||||||
[target.'cfg(not(target_os = "ios"))'.dependencies]
|
[target.'cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))'.dependencies]
|
||||||
ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "api-24"] }
|
ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "api-24"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# chanora_audio
|
||||||
|
|
||||||
|
Real-time audio subsystem: capture, Opus encoding/decoding, voice rendering, PTT gating, and audio processing. Promoted from `poc/audio-capture-playback-spike`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Engine
|
||||||
|
|
||||||
|
- **`AudioEngine`** — the primary type. Starts a platform audio backend (capture + playback), wires an `AudioTransmitGate` for PTT gating, and feeds encoded Opus frames to the protocol layer via `voice_out`. Inbound voice packets are decoded and mixed by `tsclientlib::audio::AudioHandler` and pulled by the platform output callback at 48 kHz stereo.
|
||||||
|
|
||||||
|
### Platform backends (cfg-gated)
|
||||||
|
|
||||||
|
| Target | Backend | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Android | Oboe (via `android_voice_unit`) | Requires `ndk_context` before start |
|
||||||
|
| iOS/macOS | Apple VoiceProcessingIO (`ios_voice_unit`) | Platform AEC/AGC/NS, route-change handling |
|
||||||
|
| Linux | SDL (`sdl_output`) | PulseAudio/ALSA via SDL |
|
||||||
|
| Other desktop | cpal | Fallback |
|
||||||
|
|
||||||
|
### Key modules
|
||||||
|
|
||||||
|
- **`audio_processing`** — P1 audio processing config, stats, route policy, effect ownership (Platform/Sonora/WebRTC APM)
|
||||||
|
- **`opus_voice`** — 20 ms / 48 kHz mono Opus encode/decode via `audiopus`
|
||||||
|
- **`transmit_mode`** — `TransmitMode` enum: Ptt, Continuous, VoiceActivity
|
||||||
|
- **`transmit_selector`** — `TransmitModeSelector` combining mode, hard-mute, PTT gate, permission gate, and in-channel state
|
||||||
|
- **`ptt`** — `AudioTransmitGate` (atomic bool), `PttCapabilityLevel`, `PttBackendDescriptor`
|
||||||
|
- **`ptt_backends`** — platform PTT backends: `DesktopPttBackend` (Linux portal), `FocusedPttBackend` (in-app fallback)
|
||||||
|
- **`release_tail`** — `ReleaseTailTimer` for configurable PTT release delay (default 200 ms, max 500 ms)
|
||||||
|
- **`vad`** — Voice-activity detection: Silero ONNX (desktop), WebRTC fallback, energy debug
|
||||||
|
- **`voice_render`** — mixes per-client decoded f32 PCM into the output buffer
|
||||||
|
- **`debug_wav`** — optional WAV file dump for diagnostics (DIAG_002/003)
|
||||||
|
- **`mobile_voice_backend`** — shared mobile voice-unit lifecycle abstraction
|
||||||
|
- **`frame`** — frame-aligned buffer utilities
|
||||||
|
|
||||||
|
## Public API Summary
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|---|---|
|
||||||
|
| `AudioEngine` | Start/stop audio, set gain/mute/volume, read stats |
|
||||||
|
| `AudioEngineConfig` | Capture/playback device selection, PTT initial state, processing config |
|
||||||
|
| `AudioDeviceInfo` / `AudioDeviceList` | Device enumeration |
|
||||||
|
| `AudioTransmitGate` | Atomic PTT gate |
|
||||||
|
| `TransmitMode` / `TransmitModeSelector` | Mode selection with hard-mute clamp |
|
||||||
|
| `ReleaseTailTimer` | Configurable release delay (SDD-096) |
|
||||||
|
| `PttBinding` / `PttInputClass` | PTT key binding types |
|
||||||
|
| `PttBackendDescriptor` / `PttCapabilityLevel` | Capability query |
|
||||||
|
| `AudioProcessingConfig` / `AudioProcessingStats` | P1 processing control and telemetry |
|
||||||
|
| `AudioRoute` | Speaker/Earpiece/Wired/Bluetooth enum |
|
||||||
|
| `AudioEffects` | Effect toggles (AEC/AGC/NS/HPF), all enabled by default (DEC-007..010) |
|
||||||
|
| `AudioError` | Typed error catalogue |
|
||||||
|
|
||||||
|
### Key functions
|
||||||
|
|
||||||
|
- `AudioEngine::start_with_gate(cfg, voice_out, voice_in, gate)` — construct and start
|
||||||
|
- `AudioEngine::stop()` — tear down
|
||||||
|
- `list_audio_devices()` — enumerate available input/output devices
|
||||||
|
- `select_ptt_backend()` — choose the best PTT backend for the current platform
|
||||||
|
|
||||||
|
## Platform notes
|
||||||
|
|
||||||
|
- Android requires `initChanoraContext` (NDK context) before engine start.
|
||||||
|
- iOS/macOS uses VoiceProcessingIO for platform AEC/AGC/NS in the default route.
|
||||||
|
- Desktop can use Silero ONNX VAD when the model file is available.
|
||||||
|
- `bench_seam` is exposed (`#[doc(hidden)]`) for criterion benchmarks on non-mobile targets.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crossbeam::queue::ArrayQueue;
|
||||||
|
|
||||||
|
/// Fixed-capacity PCM handoff from the Android render producer task to
|
||||||
|
/// the Oboe output callback.
|
||||||
|
pub(crate) struct AndroidRenderRing {
|
||||||
|
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidRenderRing {
|
||||||
|
pub(crate) fn new(capacity: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
frames: Arc::new(ArrayQueue::new((capacity / 2).max(1))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn producer(&self) -> AndroidRenderRingProducer {
|
||||||
|
AndroidRenderRingProducer {
|
||||||
|
frames: Arc::clone(&self.frames),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn consumer(&self) -> AndroidRenderRingConsumer {
|
||||||
|
AndroidRenderRingConsumer {
|
||||||
|
frames: Arc::clone(&self.frames),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct AndroidRenderRingProducer {
|
||||||
|
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidRenderRingProducer {
|
||||||
|
pub(crate) fn push_frame_lossy(&self, samples: &[f32]) {
|
||||||
|
for frame in samples.chunks_exact(2) {
|
||||||
|
let stereo_frame = [frame[0], frame[1]];
|
||||||
|
if self.frames.push(stereo_frame).is_err() {
|
||||||
|
let _ = self.frames.pop();
|
||||||
|
let _ = self.frames.push(stereo_frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct AndroidRenderRingConsumer {
|
||||||
|
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidRenderRingConsumer {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn drain_into_zero_filling(&self, out: &mut [f32]) {
|
||||||
|
let mut chunks = out.chunks_exact_mut(2);
|
||||||
|
for frame_out in &mut chunks {
|
||||||
|
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
|
||||||
|
frame_out.copy_from_slice(&frame);
|
||||||
|
}
|
||||||
|
for sample in chunks.into_remainder() {
|
||||||
|
*sample = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn drain_stereo_into_zero_filling(&self, out: &mut [(f32, f32)]) {
|
||||||
|
for frame_out in out {
|
||||||
|
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
|
||||||
|
*frame_out = (frame[0], frame[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn producer_drops_oldest_samples_when_ring_is_full() {
|
||||||
|
let ring = AndroidRenderRing::new(4);
|
||||||
|
let producer = ring.producer();
|
||||||
|
let consumer = ring.consumer();
|
||||||
|
|
||||||
|
producer.push_frame_lossy(&[1.0, 2.0, 3.0, 4.0]);
|
||||||
|
producer.push_frame_lossy(&[5.0, 6.0]);
|
||||||
|
|
||||||
|
let mut out = [0.0; 4];
|
||||||
|
consumer.drain_into_zero_filling(&mut out);
|
||||||
|
|
||||||
|
assert_eq!(out, [3.0, 4.0, 5.0, 6.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overflow_after_partial_consumer_drain_preserves_stereo_pairing() {
|
||||||
|
let ring = AndroidRenderRing::new(4);
|
||||||
|
let producer = ring.producer();
|
||||||
|
let consumer = ring.consumer();
|
||||||
|
|
||||||
|
producer.push_frame_lossy(&[1.0, 10.0, 2.0, 20.0]);
|
||||||
|
|
||||||
|
let mut odd_out = [9.0];
|
||||||
|
consumer.drain_into_zero_filling(&mut odd_out);
|
||||||
|
assert_eq!(odd_out, [0.0]);
|
||||||
|
|
||||||
|
producer.push_frame_lossy(&[3.0, 30.0]);
|
||||||
|
|
||||||
|
let mut out = [0.0; 4];
|
||||||
|
consumer.drain_into_zero_filling(&mut out);
|
||||||
|
|
||||||
|
assert_eq!(out, [2.0, 20.0, 3.0, 30.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consumer_zero_fills_tail_on_underrun() {
|
||||||
|
let ring = AndroidRenderRing::new(4);
|
||||||
|
let producer = ring.producer();
|
||||||
|
let consumer = ring.consumer();
|
||||||
|
|
||||||
|
producer.push_frame_lossy(&[0.25, -0.25]);
|
||||||
|
|
||||||
|
let mut out = [9.0; 4];
|
||||||
|
consumer.drain_into_zero_filling(&mut out);
|
||||||
|
|
||||||
|
assert_eq!(out, [0.25, -0.25, 0.0, 0.0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,6 @@ use crate::mobile_voice_backend::{
|
|||||||
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
|
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
|
||||||
SharingModeChoice, VoiceAudioParams,
|
SharingModeChoice, VoiceAudioParams,
|
||||||
};
|
};
|
||||||
use chanora_protocol::OutPacket;
|
|
||||||
use tsclientlib::audio::AudioHandler;
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
|
||||||
use crate::{engine::SessionAudioId, AudioError};
|
use crate::{engine::SessionAudioId, AudioError};
|
||||||
@@ -86,40 +85,11 @@ use crate::processor::AudioProcessor;
|
|||||||
|
|
||||||
const RENDER_REF_SLOTS: usize = 4;
|
const RENDER_REF_SLOTS: usize = 4;
|
||||||
const RENDER_REF_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES;
|
const RENDER_REF_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES;
|
||||||
|
const ANDROID_RENDER_PULL_SAMPLES: usize = crate::frame::FRAME_20MS_SAMPLES * 2;
|
||||||
|
const ANDROID_RENDER_RING_CAPACITY: usize = ANDROID_RENDER_PULL_SAMPLES * 5;
|
||||||
|
|
||||||
struct RenderReferenceBuffer {
|
type RenderReferenceBuffer =
|
||||||
buf: Box<[[f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]>,
|
crate::render_reference::RenderReferenceBuffer<RENDER_REF_SAMPLES, RENDER_REF_SLOTS>;
|
||||||
write_idx: std::sync::atomic::AtomicUsize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderReferenceBuffer {
|
|
||||||
fn new() -> Arc<Self> {
|
|
||||||
Arc::new(Self {
|
|
||||||
buf: Box::new([[0.0_f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]),
|
|
||||||
write_idx: std::sync::atomic::AtomicUsize::new(0),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write(&self, frame: &[f32; RENDER_REF_SAMPLES]) {
|
|
||||||
let idx = self.write_idx.load(Ordering::Relaxed);
|
|
||||||
unsafe {
|
|
||||||
let slot = &self.buf[idx] as *const [f32; RENDER_REF_SAMPLES]
|
|
||||||
as *mut [f32; RENDER_REF_SAMPLES];
|
|
||||||
(*slot).copy_from_slice(frame);
|
|
||||||
}
|
|
||||||
self.write_idx
|
|
||||||
.store((idx + 1) % RENDER_REF_SLOTS, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_latest(&self) -> [f32; RENDER_REF_SAMPLES] {
|
|
||||||
let wi = self.write_idx.load(Ordering::Relaxed);
|
|
||||||
let ri = (wi + RENDER_REF_SLOTS - 1) % RENDER_REF_SLOTS;
|
|
||||||
self.buf[ri]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe impl Send for RenderReferenceBuffer {}
|
|
||||||
unsafe impl Sync for RenderReferenceBuffer {}
|
|
||||||
|
|
||||||
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
|
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
|
||||||
//
|
//
|
||||||
@@ -138,9 +108,8 @@ struct AndroidCaptureState {
|
|||||||
encoder: OpusEncoder,
|
encoder: OpusEncoder,
|
||||||
pcm_accum: Vec<i16>,
|
pcm_accum: Vec<i16>,
|
||||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||||
@@ -164,7 +133,7 @@ struct AndroidCaptureState {
|
|||||||
|
|
||||||
impl AndroidCaptureState {
|
impl AndroidCaptureState {
|
||||||
fn new(
|
fn new(
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: mpsc::Sender<chanora_protocol::OutPacket>,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
frames_sent: Arc<AtomicU32>,
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
@@ -188,9 +157,12 @@ impl AndroidCaptureState {
|
|||||||
encoder,
|
encoder,
|
||||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx,
|
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||||
|
voice_out_tx,
|
||||||
|
frames_sent.clone(),
|
||||||
|
"android",
|
||||||
|
)?,
|
||||||
transmit_active,
|
transmit_active,
|
||||||
frames_sent,
|
|
||||||
mic_gain,
|
mic_gain,
|
||||||
voice_activity_selector,
|
voice_activity_selector,
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||||
@@ -221,8 +193,10 @@ impl AndroidCaptureState {
|
|||||||
self.audio_processing_stats
|
self.audio_processing_stats
|
||||||
.record_callback_frames(samples.len() as u64);
|
.record_callback_frames(samples.len() as u64);
|
||||||
if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ {
|
if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ {
|
||||||
let resampled = self.resample_capture_to_48k(samples);
|
self.resample_capture_to_48k(samples);
|
||||||
|
let resampled = std::mem::take(&mut self.resample_scratch);
|
||||||
self.ingest_48k_i16(&resampled);
|
self.ingest_48k_i16(&resampled);
|
||||||
|
self.resample_scratch = resampled;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.ingest_48k_i16(samples);
|
self.ingest_48k_i16(samples);
|
||||||
@@ -241,6 +215,7 @@ impl AndroidCaptureState {
|
|||||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||||
let frame = self.pending_10ms;
|
let frame = self.pending_10ms;
|
||||||
self.process_10ms_capture_frame(&frame);
|
self.process_10ms_capture_frame(&frame);
|
||||||
|
self.encode_complete_20ms_frames();
|
||||||
self.pending_10ms_len = 0;
|
self.pending_10ms_len = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,6 +225,10 @@ impl AndroidCaptureState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.encode_complete_20ms_frames();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_complete_20ms_frames(&mut self) {
|
||||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||||
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
||||||
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
||||||
@@ -258,7 +237,6 @@ impl AndroidCaptureState {
|
|||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
crate::opus_voice::send_voip_frame(
|
crate::opus_voice::send_voip_frame(
|
||||||
&self.voice_out_tx,
|
&self.voice_out_tx,
|
||||||
&self.frames_sent,
|
|
||||||
&self.opus_out,
|
&self.opus_out,
|
||||||
len,
|
len,
|
||||||
|| {
|
|| {
|
||||||
@@ -286,35 +264,18 @@ impl AndroidCaptureState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> Vec<i16> {
|
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> usize {
|
||||||
if samples.is_empty() {
|
let result = crate::capture_resampler::resample_capture_to_48k(
|
||||||
return Vec::new();
|
samples,
|
||||||
|
self.input_sample_rate_hz,
|
||||||
|
&mut self.resample_pos,
|
||||||
|
&mut self.resample_last,
|
||||||
|
&mut self.resample_scratch,
|
||||||
|
);
|
||||||
|
if result.dropped {
|
||||||
|
self.audio_processing_stats.increment_callback_xrun();
|
||||||
}
|
}
|
||||||
self.resample_scratch.clear();
|
result.output_len
|
||||||
let ratio = self.input_sample_rate_hz as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
|
|
||||||
let mut pos = self.resample_pos;
|
|
||||||
while pos < samples.len() as f64 {
|
|
||||||
let i = pos.floor() as isize;
|
|
||||||
let frac = pos - i as f64;
|
|
||||||
let a = if i <= 0 {
|
|
||||||
self.resample_last as f64
|
|
||||||
} else {
|
|
||||||
samples[(i - 1) as usize] as f64
|
|
||||||
};
|
|
||||||
let b = if i < samples.len() as isize {
|
|
||||||
samples[i as usize] as f64
|
|
||||||
} else {
|
|
||||||
a
|
|
||||||
};
|
|
||||||
let value = (a + frac * (b - a))
|
|
||||||
.round()
|
|
||||||
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
|
|
||||||
self.resample_scratch.push(value);
|
|
||||||
pos += ratio;
|
|
||||||
}
|
|
||||||
self.resample_pos = pos - samples.len() as f64;
|
|
||||||
self.resample_last = *samples.last().unwrap_or(&self.resample_last);
|
|
||||||
self.resample_scratch.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_input_sample_rate_hz(&mut self, sample_rate_hz: u32) {
|
fn set_input_sample_rate_hz(&mut self, sample_rate_hz: u32) {
|
||||||
@@ -393,15 +354,9 @@ impl AndroidCaptureState {
|
|||||||
self.fallback_warned_backend = None;
|
self.fallback_warned_backend = None;
|
||||||
match vad_backend {
|
match vad_backend {
|
||||||
crate::VadBackend::SileroOnnx => {
|
crate::VadBackend::SileroOnnx => {
|
||||||
let path = crate::vad::silero_model_bundle_path();
|
self.silero_vad_worker = None;
|
||||||
self.silero_vad_worker =
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&path);
|
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||||
if self.silero_vad_worker.is_none() {
|
|
||||||
warn!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
"android: Silero VAD model not found at {path}; falling back to WebRTC VAD"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
self.silero_vad_worker = None;
|
self.silero_vad_worker = None;
|
||||||
@@ -444,7 +399,10 @@ impl AndroidCaptureState {
|
|||||||
} else {
|
} else {
|
||||||
used_fallback_vad = true;
|
used_fallback_vad = true;
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
self.mark_vad_fallback_active(vad_backend);
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
|
&mut self.vad_detector,
|
||||||
|
&frame,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
@@ -472,21 +430,19 @@ impl AndroidCaptureState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let gain = self.mic_gain;
|
if crate::capture_accumulator::append_processed_i16_bounded(
|
||||||
if (gain - 1.0).abs() < f32::EPSILON {
|
&mut self.pcm_accum,
|
||||||
self.pcm_accum
|
&frame,
|
||||||
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
self.mic_gain,
|
||||||
} else {
|
) {
|
||||||
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
self.audio_processing_stats.increment_callback_xrun();
|
||||||
let scaled = (crate::frame::f32_to_i16(s) as f32) * gain;
|
|
||||||
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct InputCallback {
|
struct InputCallback {
|
||||||
state: Arc<Mutex<AndroidCaptureState>>,
|
state: Arc<Mutex<AndroidCaptureState>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
event_tx: BackendEventTx,
|
event_tx: BackendEventTx,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,9 +454,13 @@ impl AudioInputCallback for InputCallback {
|
|||||||
_stream: &mut dyn AudioInputStreamSafe,
|
_stream: &mut dyn AudioInputStreamSafe,
|
||||||
frames: &[i16],
|
frames: &[i16],
|
||||||
) -> DataCallbackResult {
|
) -> DataCallbackResult {
|
||||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
let _ = catch_unwind(AssertUnwindSafe(|| match self.state.try_lock() {
|
||||||
if let Ok(mut s) = self.state.lock() {
|
Ok(mut s) => s.ingest_i16(frames),
|
||||||
s.ingest_i16(frames);
|
Err(std::sync::TryLockError::WouldBlock) => {
|
||||||
|
self.audio_processing_stats.increment_callback_xrun();
|
||||||
|
}
|
||||||
|
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||||
|
warn!(target: "chanora_audio", "android: capture state poisoned: {e}");
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
DataCallbackResult::Continue
|
DataCallbackResult::Continue
|
||||||
@@ -522,8 +482,7 @@ impl AudioInputCallback for InputCallback {
|
|||||||
// writes stereo f32 directly to the Oboe output buffer.
|
// writes stereo f32 directly to the Oboe output buffer.
|
||||||
|
|
||||||
struct OutputCallback {
|
struct OutputCallback {
|
||||||
handler: AudioHandler<SessionAudioId>,
|
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
|
||||||
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
|
||||||
output_gain: Arc<AtomicU32>,
|
output_gain: Arc<AtomicU32>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
event_tx: BackendEventTx,
|
event_tx: BackendEventTx,
|
||||||
@@ -542,47 +501,39 @@ impl AudioOutputCallback for OutputCallback {
|
|||||||
frames: &mut [(f32, f32)],
|
frames: &mut [(f32, f32)],
|
||||||
) -> DataCallbackResult {
|
) -> DataCallbackResult {
|
||||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||||
let buf: &mut [f32] =
|
self.pcm_consumer.drain_stereo_into_zero_filling(frames);
|
||||||
bytemuck::cast_slice_mut::<(f32, f32), f32>(frames);
|
|
||||||
for s in buf.iter_mut() {
|
|
||||||
*s = 0.0;
|
|
||||||
}
|
|
||||||
for cmd in self.event_consumer.drain_controls() {
|
|
||||||
match cmd {
|
|
||||||
AudioCommand::SetVolume(id, vol) => {
|
|
||||||
if let Some(q) = self.handler.get_mut_queues().get_mut(&id) {
|
|
||||||
q.volume = vol;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AudioCommand::RemoveClient(id) => {
|
|
||||||
self.handler.get_mut_queues().remove(&id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for pkt in self.event_consumer.drain_packets(50) {
|
|
||||||
if let Err(e) = self.handler.handle_packet(pkt.client_id, pkt.data) {
|
|
||||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = self.handler.fill_buffer(buf);
|
|
||||||
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
||||||
let muted = self.output_muted.load(Ordering::Relaxed);
|
let muted = self.output_muted.load(Ordering::Relaxed);
|
||||||
if muted {
|
if muted {
|
||||||
for s in buf.iter_mut() {
|
for frame in frames.iter_mut() {
|
||||||
*s = 0.0;
|
*frame = (0.0, 0.0);
|
||||||
}
|
}
|
||||||
} else if gain != 1.0 {
|
} else if gain != 1.0 {
|
||||||
for s in buf.iter_mut() {
|
for (left, right) in frames.iter_mut() {
|
||||||
*s *= gain;
|
*left *= gain;
|
||||||
|
*right *= gain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut sum_squares = 0.0_f32;
|
||||||
|
for (left, right) in frames.iter() {
|
||||||
|
sum_squares += left * left + right * right;
|
||||||
|
}
|
||||||
|
let sample_count = frames.len() * 2;
|
||||||
|
let dbfs = if sample_count == 0 {
|
||||||
|
-120.0
|
||||||
|
} else {
|
||||||
|
let rms = (sum_squares / sample_count as f32).sqrt();
|
||||||
|
if rms <= 0.000_001 {
|
||||||
|
-120.0
|
||||||
|
} else {
|
||||||
|
20.0 * rms.log10()
|
||||||
|
}
|
||||||
|
};
|
||||||
self.audio_processing_stats
|
self.audio_processing_stats
|
||||||
.update_render(crate::frame::dbfs(buf), frames.len() as u32);
|
.update_render(dbfs, frames.len() as u32);
|
||||||
|
|
||||||
for chunk in buf.chunks_exact(2) {
|
for (left, right) in frames.iter() {
|
||||||
self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 0.5;
|
self.pending_render_ref[self.pending_render_ref_len] = (left + right) * 0.5;
|
||||||
self.pending_render_ref_len += 1;
|
self.pending_render_ref_len += 1;
|
||||||
if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||||
self.render_reference.write(&self.pending_render_ref);
|
self.render_reference.write(&self.pending_render_ref);
|
||||||
@@ -614,6 +565,7 @@ impl AudioOutputCallback for OutputCallback {
|
|||||||
pub struct AndroidVoiceUnit {
|
pub struct AndroidVoiceUnit {
|
||||||
input: Option<AudioStreamAsync<OboeInput, InputCallback>>,
|
input: Option<AudioStreamAsync<OboeInput, InputCallback>>,
|
||||||
output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,
|
output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,
|
||||||
|
render_producer_shutdown: Arc<AtomicBool>,
|
||||||
|
|
||||||
// Recorded achieved values (SDD-112).
|
// Recorded achieved values (SDD-112).
|
||||||
input_perf: AchievedPerformanceMode,
|
input_perf: AchievedPerformanceMode,
|
||||||
@@ -635,11 +587,13 @@ pub struct AndroidVoiceUnit {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct HardwareEffectHandles {
|
struct HardwareEffectHandles {
|
||||||
aec: Option<jni::objects::GlobalRef>,
|
aec: Option<AndroidGlobalObject>,
|
||||||
ns: Option<jni::objects::GlobalRef>,
|
ns: Option<AndroidGlobalObject>,
|
||||||
agc: Option<jni::objects::GlobalRef>,
|
agc: Option<AndroidGlobalObject>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AndroidGlobalObject = jni::refs::Global<jni::objects::JObject<'static>>;
|
||||||
|
|
||||||
impl AndroidVoiceUnit {
|
impl AndroidVoiceUnit {
|
||||||
/// Open the input + output streams (SDD-111 + SDD-112) and,
|
/// Open the input + output streams (SDD-111 + SDD-112) and,
|
||||||
/// once a session id is available, attach SDD-113 hardware
|
/// once a session id is available, attach SDD-113 hardware
|
||||||
@@ -706,6 +660,7 @@ impl AndroidVoiceUnit {
|
|||||||
|
|
||||||
let input_cb = InputCallback {
|
let input_cb = InputCallback {
|
||||||
state: capture_state.clone(),
|
state: capture_state.clone(),
|
||||||
|
audio_processing_stats: audio_processing_stats.clone(),
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
};
|
};
|
||||||
let input_builder = input_builder.set_callback(input_cb);
|
let input_builder = input_builder.set_callback(input_cb);
|
||||||
@@ -722,7 +677,12 @@ impl AndroidVoiceUnit {
|
|||||||
error = ?e,
|
error = ?e,
|
||||||
"android: primary input stream open failed; entering fallback ladder"
|
"android: primary input stream open failed; entering fallback ladder"
|
||||||
);
|
);
|
||||||
match Self::open_input_fallback(cfg, &event_tx, capture_state.clone()) {
|
match Self::open_input_fallback(
|
||||||
|
cfg,
|
||||||
|
&event_tx,
|
||||||
|
capture_state.clone(),
|
||||||
|
audio_processing_stats.clone(),
|
||||||
|
) {
|
||||||
Ok(s) => Some(s),
|
Ok(s) => Some(s),
|
||||||
Err(fallback_err) => {
|
Err(fallback_err) => {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -789,9 +749,10 @@ impl AndroidVoiceUnit {
|
|||||||
|
|
||||||
let render_ref_for_output = render_ref_buf.clone();
|
let render_ref_for_output = render_ref_buf.clone();
|
||||||
let event_queue = params.event_producer.queue();
|
let event_queue = params.event_producer.queue();
|
||||||
|
let render_ring =
|
||||||
|
crate::android_render_ring::AndroidRenderRing::new(ANDROID_RENDER_RING_CAPACITY);
|
||||||
let output_cb = OutputCallback {
|
let output_cb = OutputCallback {
|
||||||
handler: params.handler,
|
pcm_consumer: render_ring.consumer(),
|
||||||
event_consumer: AudioEventQueue::consumer(&event_queue),
|
|
||||||
output_gain: params.output_gain.clone(),
|
output_gain: params.output_gain.clone(),
|
||||||
output_muted: params.output_muted.clone(),
|
output_muted: params.output_muted.clone(),
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
@@ -813,8 +774,7 @@ impl AndroidVoiceUnit {
|
|||||||
Self::open_output_fallback(
|
Self::open_output_fallback(
|
||||||
cfg,
|
cfg,
|
||||||
&event_tx,
|
&event_tx,
|
||||||
AudioHandler::new(),
|
render_ring.consumer(),
|
||||||
AudioEventQueue::consumer(&event_queue),
|
|
||||||
params.output_gain.clone(),
|
params.output_gain.clone(),
|
||||||
params.output_muted.clone(),
|
params.output_muted.clone(),
|
||||||
audio_processing_stats.clone(),
|
audio_processing_stats.clone(),
|
||||||
@@ -822,6 +782,11 @@ impl AndroidVoiceUnit {
|
|||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let render_producer_shutdown = Self::spawn_render_producer(
|
||||||
|
params.handler,
|
||||||
|
AudioEventQueue::consumer(&event_queue),
|
||||||
|
render_ring.producer(),
|
||||||
|
);
|
||||||
|
|
||||||
let output_frames_per_burst = output_stream.get_frames_per_burst();
|
let output_frames_per_burst = output_stream.get_frames_per_burst();
|
||||||
if output_frames_per_burst > 0 {
|
if output_frames_per_burst > 0 {
|
||||||
@@ -890,7 +855,7 @@ impl AndroidVoiceUnit {
|
|||||||
// by the capture callback's WebRtcApmProcessor.
|
// by the capture callback's WebRtcApmProcessor.
|
||||||
{
|
{
|
||||||
use crate::audio_processing::EffectOwner;
|
use crate::audio_processing::EffectOwner;
|
||||||
let mut apm_cfg = apm_config_clone.lock().unwrap();
|
let mut apm_cfg = apm_config_clone.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let hw_aec = hw_effects.aec.is_some();
|
let hw_aec = hw_effects.aec.is_some();
|
||||||
let hw_ns = hw_effects.ns.is_some();
|
let hw_ns = hw_effects.ns.is_some();
|
||||||
let hw_agc = hw_effects.agc.is_some();
|
let hw_agc = hw_effects.agc.is_some();
|
||||||
@@ -978,6 +943,7 @@ impl AndroidVoiceUnit {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
input: input_stream,
|
input: input_stream,
|
||||||
output: Some(output_stream),
|
output: Some(output_stream),
|
||||||
|
render_producer_shutdown,
|
||||||
input_perf,
|
input_perf,
|
||||||
input_share,
|
input_share,
|
||||||
output_perf,
|
output_perf,
|
||||||
@@ -995,6 +961,7 @@ impl AndroidVoiceUnit {
|
|||||||
cfg: &AndroidVoiceStreamConfig,
|
cfg: &AndroidVoiceStreamConfig,
|
||||||
event_tx: &BackendEventTx,
|
event_tx: &BackendEventTx,
|
||||||
capture_state: Arc<Mutex<AndroidCaptureState>>,
|
capture_state: Arc<Mutex<AndroidCaptureState>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
) -> Result<AudioStreamAsync<OboeInput, InputCallback>, BackendError> {
|
) -> Result<AudioStreamAsync<OboeInput, InputCallback>, BackendError> {
|
||||||
// SDD-112 items 6 & 7: explore (preset × sharing) independently
|
// SDD-112 items 6 & 7: explore (preset × sharing) independently
|
||||||
// via the pure helpers in `mobile_voice_backend`. Primary
|
// via the pure helpers in `mobile_voice_backend`. Primary
|
||||||
@@ -1031,6 +998,7 @@ impl AndroidVoiceUnit {
|
|||||||
};
|
};
|
||||||
let cb = InputCallback {
|
let cb = InputCallback {
|
||||||
state: capture_state.clone(),
|
state: capture_state.clone(),
|
||||||
|
audio_processing_stats: audio_processing_stats.clone(),
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
};
|
};
|
||||||
let builder = AudioStreamBuilder::default()
|
let builder = AudioStreamBuilder::default()
|
||||||
@@ -1066,16 +1034,14 @@ impl AndroidVoiceUnit {
|
|||||||
fn open_output_fallback(
|
fn open_output_fallback(
|
||||||
cfg: &AndroidVoiceStreamConfig,
|
cfg: &AndroidVoiceStreamConfig,
|
||||||
event_tx: &BackendEventTx,
|
event_tx: &BackendEventTx,
|
||||||
handler: AudioHandler<SessionAudioId>,
|
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
|
||||||
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
|
||||||
output_gain: Arc<AtomicU32>,
|
output_gain: Arc<AtomicU32>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
render_reference: Arc<RenderReferenceBuffer>,
|
render_reference: Arc<RenderReferenceBuffer>,
|
||||||
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
||||||
let cb = OutputCallback {
|
let cb = OutputCallback {
|
||||||
handler,
|
pcm_consumer,
|
||||||
event_consumer,
|
|
||||||
output_gain,
|
output_gain,
|
||||||
output_muted,
|
output_muted,
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
@@ -1100,6 +1066,50 @@ impl AndroidVoiceUnit {
|
|||||||
.map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
|
.map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_render_producer(
|
||||||
|
mut handler: AudioHandler<SessionAudioId>,
|
||||||
|
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
||||||
|
pcm_producer: crate::android_render_ring::AndroidRenderRingProducer,
|
||||||
|
) -> Arc<AtomicBool> {
|
||||||
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
let shutdown_for_task = shutdown.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut pull_scratch = vec![0.0_f32; ANDROID_RENDER_PULL_SAMPLES];
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
||||||
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if shutdown_for_task.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for cmd in event_consumer.drain_controls() {
|
||||||
|
match cmd {
|
||||||
|
AudioCommand::SetVolume(id, vol) => {
|
||||||
|
if let Some(q) = handler.get_mut_queues().get_mut(&id) {
|
||||||
|
q.volume = vol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioCommand::RemoveClient(id) => {
|
||||||
|
handler.get_mut_queues().remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pkt in event_consumer.drain_packets(50) {
|
||||||
|
if let Err(e) = handler.handle_packet(pkt.client_id, pkt.data) {
|
||||||
|
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pull_scratch.fill(0.0);
|
||||||
|
let _ = handler.fill_buffer(&mut pull_scratch);
|
||||||
|
pcm_producer.push_frame_lossy(&pull_scratch);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
shutdown
|
||||||
|
}
|
||||||
|
|
||||||
/// Clone of the event sender, for JNI focus / SCO listeners
|
/// Clone of the event sender, for JNI focus / SCO listeners
|
||||||
/// registered on the engine's behalf.
|
/// registered on the engine's behalf.
|
||||||
pub fn event_sender(&self) -> BackendEventTx {
|
pub fn event_sender(&self) -> BackendEventTx {
|
||||||
@@ -1151,6 +1161,7 @@ impl MobileVoiceAudioBackend for AndroidVoiceUnit {
|
|||||||
fn close(&mut self) -> Result<(), BackendError> {
|
fn close(&mut self) -> Result<(), BackendError> {
|
||||||
// SDD-115 reverse order: release hardware effects FIRST,
|
// SDD-115 reverse order: release hardware effects FIRST,
|
||||||
// then close streams.
|
// then close streams.
|
||||||
|
self.render_producer_shutdown.store(true, Ordering::Relaxed);
|
||||||
release_hardware_effects(&mut self.hw_effects);
|
release_hardware_effects(&mut self.hw_effects);
|
||||||
self.stop().ok();
|
self.stop().ok();
|
||||||
// Dropping the Option drops the underlying AudioStreamAsync
|
// Dropping the Option drops the underlying AudioStreamAsync
|
||||||
@@ -1208,6 +1219,7 @@ impl Drop for AndroidVoiceUnit {
|
|||||||
// Wrap in catch_unwind so a panic during Drop cannot unwind
|
// Wrap in catch_unwind so a panic during Drop cannot unwind
|
||||||
// into the JVM (SDD-115 callback safety).
|
// into the JVM (SDD-115 callback safety).
|
||||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
self.render_producer_shutdown.store(true, Ordering::Relaxed);
|
||||||
release_hardware_effects(&mut self.hw_effects);
|
release_hardware_effects(&mut self.hw_effects);
|
||||||
// SDD-116: clear the diagnostics slot on Drop too.
|
// SDD-116: clear the diagnostics slot on Drop too.
|
||||||
clear_android_audio_diagnostics();
|
clear_android_audio_diagnostics();
|
||||||
@@ -1287,62 +1299,71 @@ fn attach_hardware_effects_inner(
|
|||||||
session_id: AudioSessionId,
|
session_id: AudioSessionId,
|
||||||
effects: &crate::AudioEffects,
|
effects: &crate::AudioEffects,
|
||||||
) -> HardwareEffectHandles {
|
) -> HardwareEffectHandles {
|
||||||
|
with_android_env("hardware effects", |env| {
|
||||||
|
let mut handles = HardwareEffectHandles::default();
|
||||||
|
if effects.aec {
|
||||||
|
handles.aec = create_effect(
|
||||||
|
env,
|
||||||
|
"android/media/audiofx/AcousticEchoCanceler",
|
||||||
|
session_id,
|
||||||
|
"AEC",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if effects.noise_suppression {
|
||||||
|
handles.ns = create_effect(
|
||||||
|
env,
|
||||||
|
"android/media/audiofx/NoiseSuppressor",
|
||||||
|
session_id,
|
||||||
|
"NS",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if effects.agc {
|
||||||
|
handles.agc = create_effect(
|
||||||
|
env,
|
||||||
|
"android/media/audiofx/AutomaticGainControl",
|
||||||
|
session_id,
|
||||||
|
"AGC",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
handles
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_android_env<R>(
|
||||||
|
operation: &str,
|
||||||
|
op: impl for<'local> FnOnce(&mut jni::Env<'local>) -> R,
|
||||||
|
) -> Option<R> {
|
||||||
let ctx = ndk_context::android_context();
|
let ctx = ndk_context::android_context();
|
||||||
if ctx.vm().is_null() {
|
if ctx.vm().is_null() {
|
||||||
warn!(
|
warn!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
"android: ndk_context vm null; cannot bind hardware effects (software fallback engages)"
|
operation,
|
||||||
|
"android: ndk_context vm null; JNI call skipped"
|
||||||
);
|
);
|
||||||
return HardwareEffectHandles::default();
|
return None;
|
||||||
}
|
}
|
||||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "android: JavaVM::from_raw failed; effects not bound");
|
|
||||||
return HardwareEffectHandles::default();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut env = match jvm.attach_current_thread() {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "android: attach_current_thread failed; effects not bound");
|
|
||||||
return HardwareEffectHandles::default();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut handles = HardwareEffectHandles::default();
|
let jvm = unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) };
|
||||||
if effects.aec {
|
match jvm.attach_current_thread(|env| Ok::<R, jni::errors::Error>(op(env))) {
|
||||||
handles.aec = create_effect(
|
Ok(value) => Some(value),
|
||||||
&mut env,
|
Err(e) => {
|
||||||
"android/media/audiofx/AcousticEchoCanceler",
|
warn!(target: "chanora_audio", error = %e, operation, "android: attach_current_thread failed");
|
||||||
session_id,
|
None
|
||||||
"AEC",
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if effects.noise_suppression {
|
|
||||||
handles.ns = create_effect(
|
|
||||||
&mut env,
|
|
||||||
"android/media/audiofx/NoiseSuppressor",
|
|
||||||
session_id,
|
|
||||||
"NS",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if effects.agc {
|
|
||||||
handles.agc = create_effect(
|
|
||||||
&mut env,
|
|
||||||
"android/media/audiofx/AutomaticGainControl",
|
|
||||||
session_id,
|
|
||||||
"AGC",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
handles
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SDD-113 item 3: probe the static `isAvailable()` on each effect
|
/// SDD-113 item 3: probe the static `isAvailable()` on each effect
|
||||||
/// class before calling `create(int)`. Returns `false` on any JNI
|
/// class before calling `create(int)`. Returns `false` on any JNI
|
||||||
/// failure so the caller engages the software fallback.
|
/// failure so the caller engages the software fallback.
|
||||||
fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, label: &str) -> bool {
|
fn effect_is_available(env: &mut jni::Env<'_>, class: &jni::objects::JClass, label: &str) -> bool {
|
||||||
match env.call_static_method(class, "isAvailable", "()Z", &[]) {
|
match env.call_static_method(
|
||||||
|
class,
|
||||||
|
jni::jni_str!("isAvailable"),
|
||||||
|
jni::jni_sig!("()Z"),
|
||||||
|
&[],
|
||||||
|
) {
|
||||||
Ok(v) => match v.z() {
|
Ok(v) => match v.z() {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1360,14 +1381,14 @@ fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, labe
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn create_effect(
|
fn create_effect(
|
||||||
env: &mut jni::JNIEnv,
|
env: &mut jni::Env<'_>,
|
||||||
fqcn: &str,
|
fqcn: &str,
|
||||||
session_id: AudioSessionId,
|
session_id: AudioSessionId,
|
||||||
label: &str,
|
label: &str,
|
||||||
) -> Option<jni::objects::GlobalRef> {
|
) -> Option<AndroidGlobalObject> {
|
||||||
use jni::objects::JValue;
|
use jni::objects::JValue;
|
||||||
// Class.create(int) -> ClassInstance|null
|
// Class.create(int) -> ClassInstance|null
|
||||||
let class = match env.find_class(fqcn) {
|
let class = match env.find_class(jni::strings::JNIString::new(fqcn)) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
|
warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
|
||||||
@@ -1383,10 +1404,18 @@ fn create_effect(
|
|||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let create_sig = match jni::signature::RuntimeMethodSignature::from_str(format!("(I)L{fqcn};"))
|
||||||
|
{
|
||||||
|
Ok(sig) => sig,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(target: "chanora_audio", error = %e, effect = label, "android: create() signature parse failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
let inst = match env.call_static_method(
|
let inst = match env.call_static_method(
|
||||||
&class,
|
&class,
|
||||||
"create",
|
jni::jni_str!("create"),
|
||||||
&format!("(I)L{fqcn};"),
|
create_sig.method_signature(),
|
||||||
&[JValue::Int(session_id)],
|
&[JValue::Int(session_id)],
|
||||||
) {
|
) {
|
||||||
Ok(v) => match v.l() {
|
Ok(v) => match v.l() {
|
||||||
@@ -1411,8 +1440,8 @@ fn create_effect(
|
|||||||
// setEnabled(true) -> int (success code)
|
// setEnabled(true) -> int (success code)
|
||||||
if let Err(e) = env.call_method(
|
if let Err(e) = env.call_method(
|
||||||
&inst,
|
&inst,
|
||||||
"setEnabled",
|
jni::jni_str!("setEnabled"),
|
||||||
"(Z)I",
|
jni::jni_sig!("(Z)I"),
|
||||||
&[JValue::Bool(jni::sys::JNI_TRUE)],
|
&[JValue::Bool(jni::sys::JNI_TRUE)],
|
||||||
) {
|
) {
|
||||||
let _ = env.exception_clear();
|
let _ = env.exception_clear();
|
||||||
@@ -1448,34 +1477,28 @@ fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) {
|
|||||||
if aec.is_none() && ns.is_none() && agc.is_none() {
|
if aec.is_none() && ns.is_none() && agc.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let ctx = ndk_context::android_context();
|
let _ = with_android_env("release hardware effects", |env| {
|
||||||
if ctx.vm().is_null() {
|
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
||||||
return;
|
if let Some(g) = effect {
|
||||||
}
|
let _ = env.call_method(
|
||||||
// SAFETY: vm is non-null and owned for process lifetime via JNI_OnLoad.
|
g.as_obj(),
|
||||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
jni::jni_str!("setEnabled"),
|
||||||
Ok(v) => v,
|
jni::jni_sig!("(Z)I"),
|
||||||
Err(_) => return,
|
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
||||||
};
|
);
|
||||||
let mut env = match jvm.attach_current_thread() {
|
env.exception_clear();
|
||||||
Ok(e) => e,
|
let _ = env.call_method(
|
||||||
Err(_) => return,
|
g.as_obj(),
|
||||||
};
|
jni::jni_str!("release"),
|
||||||
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
jni::jni_sig!("()V"),
|
||||||
if let Some(g) = effect {
|
&[],
|
||||||
let _ = env.call_method(
|
);
|
||||||
g.as_obj(),
|
env.exception_clear();
|
||||||
"setEnabled",
|
drop(g);
|
||||||
"(Z)I",
|
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
|
||||||
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
}
|
||||||
);
|
|
||||||
let _ = env.exception_clear();
|
|
||||||
let _ = env.call_method(g.as_obj(), "release", "()V", &[]);
|
|
||||||
let _ = env.exception_clear();
|
|
||||||
drop(g);
|
|
||||||
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Process-global BackendEvent sender for JNI callbacks --------
|
// --- Process-global BackendEvent sender for JNI callbacks --------
|
||||||
@@ -1550,7 +1573,7 @@ pub fn chanora_android_stop_voice_service() -> bool {
|
|||||||
fn call_voice_service_static(method: &str) -> bool {
|
fn call_voice_service_static(method: &str) -> bool {
|
||||||
use jni::objects::{JObject, JValue};
|
use jni::objects::{JObject, JValue};
|
||||||
let ctx = ndk_context::android_context();
|
let ctx = ndk_context::android_context();
|
||||||
if ctx.vm().is_null() || ctx.context().is_null() {
|
if ctx.context().is_null() {
|
||||||
warn!(
|
warn!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
method,
|
method,
|
||||||
@@ -1558,54 +1581,41 @@ fn call_voice_service_static(method: &str) -> bool {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// SAFETY: vm/context populated by chanora_bridge::android_init at
|
|
||||||
// JNI_OnLoad + initChanoraContext; both pointers are valid for
|
with_android_env("voice foreground service", |env| {
|
||||||
// the process lifetime.
|
// SAFETY: ndk_context::context() is the application Context
|
||||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
// jobject; valid global ref for process lifetime.
|
||||||
Ok(v) => v,
|
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
||||||
Err(e) => {
|
let class = match load_app_class(env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
||||||
warn!(target: "chanora_audio", error = %e, method, "android: JavaVM::from_raw failed");
|
Some(c) => c,
|
||||||
return false;
|
None => return false,
|
||||||
|
};
|
||||||
|
match env.call_static_method(
|
||||||
|
&class,
|
||||||
|
jni::strings::JNIString::new(method),
|
||||||
|
jni::jni_sig!("(Landroid/content/Context;)V"),
|
||||||
|
&[JValue::Object(&context_obj)],
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
info!(target: "chanora_audio", method, "android: voice foreground service call dispatched");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
env.exception_clear();
|
||||||
|
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
})
|
||||||
let mut env = match jvm.attach_current_thread() {
|
.unwrap_or(false)
|
||||||
Ok(e) => e,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(target: "chanora_audio", error = %e, method, "android: attach_current_thread failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// SAFETY: ndk_context::context() is the application Context
|
|
||||||
// jobject; valid global ref for process lifetime.
|
|
||||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
|
||||||
let class = match load_app_class(&mut env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
|
||||||
Some(c) => c,
|
|
||||||
None => return false,
|
|
||||||
};
|
|
||||||
match env.call_static_method(
|
|
||||||
&class,
|
|
||||||
method,
|
|
||||||
"(Landroid/content/Context;)V",
|
|
||||||
&[JValue::Object(&context_obj)],
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
|
||||||
info!(target: "chanora_audio", method, "android: voice foreground service call dispatched");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = env.exception_clear();
|
|
||||||
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_app_class<'local>(
|
fn load_app_class<'local>(
|
||||||
env: &mut jni::JNIEnv<'local>,
|
env: &mut jni::Env<'local>,
|
||||||
context_obj: &jni::objects::JObject<'local>,
|
context_obj: &jni::objects::JObject<'local>,
|
||||||
slash_name: &str,
|
slash_name: &str,
|
||||||
) -> Option<jni::objects::JClass<'local>> {
|
) -> Option<jni::objects::JClass<'local>> {
|
||||||
match env.find_class(slash_name) {
|
match env.find_class(jni::strings::JNIString::new(slash_name)) {
|
||||||
Ok(c) => return Some(c),
|
Ok(c) => return Some(c),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = env.exception_clear();
|
let _ = env.exception_clear();
|
||||||
@@ -1616,8 +1626,8 @@ fn load_app_class<'local>(
|
|||||||
let loader = match env
|
let loader = match env
|
||||||
.call_method(
|
.call_method(
|
||||||
context_obj,
|
context_obj,
|
||||||
"getClassLoader",
|
jni::jni_str!("getClassLoader"),
|
||||||
"()Ljava/lang/ClassLoader;",
|
jni::jni_sig!("()Ljava/lang/ClassLoader;"),
|
||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
.and_then(|v| v.l())
|
.and_then(|v| v.l())
|
||||||
@@ -1642,13 +1652,20 @@ fn load_app_class<'local>(
|
|||||||
match env
|
match env
|
||||||
.call_method(
|
.call_method(
|
||||||
&loader,
|
&loader,
|
||||||
"loadClass",
|
jni::jni_str!("loadClass"),
|
||||||
"(Ljava/lang/String;)Ljava/lang/Class;",
|
jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Class;"),
|
||||||
&[jni::objects::JValue::Object(&class_name_obj)],
|
&[jni::objects::JValue::Object(&class_name_obj)],
|
||||||
)
|
)
|
||||||
.and_then(|v| v.l())
|
.and_then(|v| v.l())
|
||||||
{
|
{
|
||||||
Ok(class_obj) => Some(jni::objects::JClass::from(class_obj)),
|
Ok(class_obj) => match env.cast_local::<jni::objects::JClass>(class_obj) {
|
||||||
|
Ok(class) => Some(class),
|
||||||
|
Err(e) => {
|
||||||
|
env.exception_clear();
|
||||||
|
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass returned non-Class object");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = env.exception_clear();
|
let _ = env.exception_clear();
|
||||||
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed");
|
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed");
|
||||||
@@ -1679,7 +1696,7 @@ fn load_app_class<'local>(
|
|||||||
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange<
|
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange<
|
||||||
'local,
|
'local,
|
||||||
>(
|
>(
|
||||||
_env: jni::JNIEnv<'local>,
|
_env: jni::EnvUnowned<'local>,
|
||||||
_class: jni::objects::JClass<'local>,
|
_class: jni::objects::JClass<'local>,
|
||||||
state: jni::sys::jint,
|
state: jni::sys::jint,
|
||||||
) {
|
) {
|
||||||
@@ -1717,7 +1734,7 @@ pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusContro
|
|||||||
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange<
|
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange<
|
||||||
'local,
|
'local,
|
||||||
>(
|
>(
|
||||||
_env: jni::JNIEnv<'local>,
|
_env: jni::EnvUnowned<'local>,
|
||||||
_class: jni::objects::JClass<'local>,
|
_class: jni::objects::JClass<'local>,
|
||||||
state: jni::sys::jint,
|
state: jni::sys::jint,
|
||||||
) {
|
) {
|
||||||
@@ -1766,7 +1783,7 @@ pub fn chanora_android_stop_bluetooth_sco() -> bool {
|
|||||||
fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
||||||
use jni::objects::{JObject, JValue};
|
use jni::objects::{JObject, JValue};
|
||||||
let ctx = ndk_context::android_context();
|
let ctx = ndk_context::android_context();
|
||||||
if ctx.vm().is_null() || ctx.context().is_null() {
|
if ctx.context().is_null() {
|
||||||
warn!(
|
warn!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
class = fqcn,
|
class = fqcn,
|
||||||
@@ -1775,39 +1792,29 @@ fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
|
||||||
Ok(v) => v,
|
with_android_env("static context call", |env| {
|
||||||
Err(e) => {
|
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
||||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: JavaVM::from_raw failed");
|
let class = match load_app_class(env, &context_obj, fqcn) {
|
||||||
return false;
|
Some(c) => c,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
match env.call_static_method(
|
||||||
|
&class,
|
||||||
|
jni::strings::JNIString::new(method),
|
||||||
|
jni::jni_sig!("(Landroid/content/Context;)V"),
|
||||||
|
&[JValue::Object(&context_obj)],
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
info!(target: "chanora_audio", class = fqcn, method, "android: dispatched");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
env.exception_clear();
|
||||||
|
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
})
|
||||||
let mut env = match jvm.attach_current_thread() {
|
.unwrap_or(false)
|
||||||
Ok(e) => e,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: attach_current_thread failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
|
||||||
let class = match load_app_class(&mut env, &context_obj, fqcn) {
|
|
||||||
Some(c) => c,
|
|
||||||
None => return false,
|
|
||||||
};
|
|
||||||
match env.call_static_method(
|
|
||||||
&class,
|
|
||||||
method,
|
|
||||||
"(Landroid/content/Context;)V",
|
|
||||||
&[JValue::Object(&context_obj)],
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
|
||||||
info!(target: "chanora_audio", class = fqcn, method, "android: dispatched");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = env.exception_clear();
|
|
||||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ pub enum AudioCommand {
|
|||||||
/// Set a client's output volume.
|
/// Set a client's output volume.
|
||||||
SetVolume(SessionAudioId, f32),
|
SetVolume(SessionAudioId, f32),
|
||||||
/// Remove a client's decode queue.
|
/// Remove a client's decode queue.
|
||||||
// TODO: Wire to client disconnect path; handled in callback but no
|
// TRACKED(TODO-005): Wire to client disconnect path; handled in callback
|
||||||
// producer currently pushes this command.
|
// but no producer currently pushes this command.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
RemoveClient(SessionAudioId),
|
RemoveClient(SessionAudioId),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,10 +56,8 @@ impl AudioRoute {
|
|||||||
/// iOS voice-processing mode.
|
/// iOS voice-processing mode.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum IosVoiceProcessingMode {
|
pub enum IosVoiceProcessingMode {
|
||||||
/// Shipping default: Apple VoiceProcessingIO owns AEC/NS/AGC.
|
/// Apple VoiceProcessingIO owns AEC/NS/AGC.
|
||||||
PlatformVoiceProcessing,
|
PlatformVoiceProcessing,
|
||||||
/// Experimental raw capture-processing path.
|
|
||||||
SonoraExperimental,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processing backend selected by policy/config.
|
/// Processing backend selected by policy/config.
|
||||||
@@ -195,42 +193,23 @@ impl AudioProcessingConfig {
|
|||||||
"bluetooth_a2dp is output-only and cannot transmit duplex voice".to_string(),
|
"bluetooth_a2dp is output-only and cannot transmit duplex voice".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if self.ios_mode == IosVoiceProcessingMode::PlatformVoiceProcessing
|
if self.processing_backend == AudioBackend::Sonora
|
||||||
&& (self.processing_backend == AudioBackend::Sonora
|
|| self.processing_backend == AudioBackend::WebrtcApm
|
||||||
|| self.processing_backend == AudioBackend::WebrtcApm
|
|| self.aec == EffectOwner::Sonora
|
||||||
|| self.aec == EffectOwner::Sonora
|
|| self.aec == EffectOwner::WebrtcApm
|
||||||
|| self.aec == EffectOwner::WebrtcApm
|
|| self.ns == EffectOwner::Sonora
|
||||||
|| self.ns == EffectOwner::Sonora
|
|| self.ns == EffectOwner::WebrtcApm
|
||||||
|| self.ns == EffectOwner::WebrtcApm
|
|| self.agc == EffectOwner::Sonora
|
||||||
|| self.agc == EffectOwner::Sonora
|
|| self.agc == EffectOwner::WebrtcApm
|
||||||
|| self.agc == EffectOwner::WebrtcApm)
|
|
||||||
{
|
{
|
||||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||||
"software audio processing cannot be enabled with iOS VoiceProcessingIO"
|
"software audio processing cannot be enabled with iOS VoiceProcessingIO"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental
|
|
||||||
&& self.processing_backend != AudioBackend::WebrtcApm
|
|
||||||
{
|
|
||||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
|
||||||
"ios raw processing mode requires the WebRTC APM backend".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Demote a failed VAD backend to the WebRTC fallback.
|
|
||||||
///
|
|
||||||
/// Returns `true` when the config changed.
|
|
||||||
pub fn disable_failed_vad_backend(&mut self, failed_backend: VadBackend) -> bool {
|
|
||||||
if self.vad_backend == failed_backend && failed_backend != VadBackend::WebrtcVad {
|
|
||||||
self.vad_backend = VadBackend::WebrtcVad;
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -261,46 +240,6 @@ mod tests {
|
|||||||
|
|
||||||
assert!(config.validate_for_ios().is_err());
|
assert!(config.validate_for_ios().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn raw_processing_allows_full_webrtc_apm_chain() {
|
|
||||||
let config = AudioProcessingConfig {
|
|
||||||
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
|
|
||||||
processing_backend: AudioBackend::WebrtcApm,
|
|
||||||
aec: EffectOwner::WebrtcApm,
|
|
||||||
ns: EffectOwner::WebrtcApm,
|
|
||||||
agc: EffectOwner::WebrtcApm,
|
|
||||||
..AudioProcessingConfig::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(config.validate_for_ios().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn raw_processing_rejects_non_webrtc_apm_backend() {
|
|
||||||
let config = AudioProcessingConfig {
|
|
||||||
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
|
|
||||||
processing_backend: AudioBackend::PlatformVoiceProcessing,
|
|
||||||
aec: EffectOwner::WebrtcApm,
|
|
||||||
ns: EffectOwner::WebrtcApm,
|
|
||||||
agc: EffectOwner::WebrtcApm,
|
|
||||||
..AudioProcessingConfig::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(config.validate_for_ios().is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn disable_failed_vad_backend_demotes_to_webrtc() {
|
|
||||||
let mut config = AudioProcessingConfig {
|
|
||||||
vad_backend: VadBackend::SileroOnnx,
|
|
||||||
..AudioProcessingConfig::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(config.disable_failed_vad_backend(VadBackend::SileroOnnx));
|
|
||||||
assert_eq!(config.vad_backend, VadBackend::WebrtcVad);
|
|
||||||
assert!(!config.disable_failed_vad_backend(VadBackend::SileroOnnx));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runtime audio processing stats exposed to bridge/UI diagnostics.
|
/// Runtime audio processing stats exposed to bridge/UI diagnostics.
|
||||||
@@ -404,10 +343,8 @@ impl Default for SharedAudioProcessingStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SharedAudioProcessingStats {
|
impl SharedAudioProcessingStats {
|
||||||
/// Store the raw input dBFS level (desktop capture path).
|
/// Store the raw input dBFS level for capture paths that do not
|
||||||
/// Mobile platforms use [`Self::update_capture`] instead, which
|
/// update the full processing/VAD snapshot on this callback.
|
||||||
/// also records VAD state; this lighter method is for the cpal
|
|
||||||
/// capture path that has no VAD pipeline.
|
|
||||||
pub fn set_input_dbfs(&self, dbfs: f32) {
|
pub fn set_input_dbfs(&self, dbfs: f32) {
|
||||||
self.input_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
|
self.input_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
pub(crate) fn append_processed_i16_bounded(
|
||||||
|
pcm_accum: &mut Vec<i16>,
|
||||||
|
frame: &[f32],
|
||||||
|
gain: f32,
|
||||||
|
) -> bool {
|
||||||
|
if (gain - 1.0).abs() < f32::EPSILON {
|
||||||
|
for src in frame.iter().copied() {
|
||||||
|
if pcm_accum.len() == pcm_accum.capacity() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
pcm_accum.push(crate::frame::f32_to_i16(src));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for src in frame.iter().copied() {
|
||||||
|
if pcm_accum.len() == pcm_accum.capacity() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let scaled = (crate::frame::f32_to_i16(src) as f32) * gain;
|
||||||
|
pcm_accum.push(scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn append_i16_bounded(pcm_accum: &mut Vec<i16>, frame: &[i16]) -> bool {
|
||||||
|
for src in frame.iter().copied() {
|
||||||
|
if pcm_accum.len() == pcm_accum.capacity() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
pcm_accum.push(src);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{append_i16_bounded, append_processed_i16_bounded};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_processed_i16_bounded_does_not_grow_when_full() {
|
||||||
|
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
|
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
|
||||||
|
let warmed_capacity = accum.capacity();
|
||||||
|
let warmed_ptr = accum.as_ptr();
|
||||||
|
|
||||||
|
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
|
||||||
|
|
||||||
|
assert!(dropped);
|
||||||
|
assert_eq!(accum.len(), warmed_capacity);
|
||||||
|
assert_eq!(accum.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_processed_i16_bounded_preserves_expected_10ms_append() {
|
||||||
|
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
|
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
|
||||||
|
let warmed_capacity = accum.capacity();
|
||||||
|
let warmed_ptr = accum.as_ptr();
|
||||||
|
|
||||||
|
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
|
||||||
|
|
||||||
|
assert!(!dropped);
|
||||||
|
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
|
||||||
|
assert_eq!(accum.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_i16_bounded_does_not_grow_when_preroll_exceeds_capacity() {
|
||||||
|
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
|
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
|
||||||
|
let warmed_capacity = accum.capacity();
|
||||||
|
let warmed_ptr = accum.as_ptr();
|
||||||
|
|
||||||
|
let dropped = append_i16_bounded(&mut accum, &frame);
|
||||||
|
|
||||||
|
assert!(dropped);
|
||||||
|
assert_eq!(accum.len(), warmed_capacity);
|
||||||
|
assert_eq!(accum.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_i16_bounded_preserves_expected_10ms_append() {
|
||||||
|
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
|
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
|
||||||
|
let warmed_capacity = accum.capacity();
|
||||||
|
let warmed_ptr = accum.as_ptr();
|
||||||
|
|
||||||
|
let dropped = append_i16_bounded(&mut accum, &frame);
|
||||||
|
|
||||||
|
assert!(!dropped);
|
||||||
|
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
|
||||||
|
assert_eq!(accum.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_i16_bounded_preserves_full_vad_preroll_window() {
|
||||||
|
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
|
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES * 16);
|
||||||
|
let warmed_capacity = accum.capacity();
|
||||||
|
let warmed_ptr = accum.as_ptr();
|
||||||
|
|
||||||
|
for _ in 0..16 {
|
||||||
|
assert!(!append_i16_bounded(&mut accum, &frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES * 16);
|
||||||
|
assert_eq!(accum.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||||
|
assert!(append_i16_bounded(&mut accum, &frame));
|
||||||
|
assert_eq!(accum.len(), warmed_capacity);
|
||||||
|
assert_eq!(accum.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
pub(crate) struct CaptureResampleResult {
|
||||||
|
pub(crate) output_len: usize,
|
||||||
|
pub(crate) dropped: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resample_capture_to_48k(
|
||||||
|
samples: &[i16],
|
||||||
|
input_sample_rate_hz: u32,
|
||||||
|
resample_pos: &mut f64,
|
||||||
|
resample_last: &mut i16,
|
||||||
|
scratch: &mut Vec<i16>,
|
||||||
|
) -> CaptureResampleResult {
|
||||||
|
scratch.clear();
|
||||||
|
if samples.is_empty() {
|
||||||
|
return CaptureResampleResult {
|
||||||
|
output_len: 0,
|
||||||
|
dropped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let ratio = input_sample_rate_hz.max(1) as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
|
||||||
|
let mut pos = *resample_pos;
|
||||||
|
let mut dropped = false;
|
||||||
|
while pos < samples.len() as f64 {
|
||||||
|
let i = pos.floor() as isize;
|
||||||
|
let frac = pos - i as f64;
|
||||||
|
let a = if i <= 0 {
|
||||||
|
*resample_last as f64
|
||||||
|
} else {
|
||||||
|
samples[(i - 1) as usize] as f64
|
||||||
|
};
|
||||||
|
let b = if i < samples.len() as isize {
|
||||||
|
samples[i as usize] as f64
|
||||||
|
} else {
|
||||||
|
a
|
||||||
|
};
|
||||||
|
let value = (a + frac * (b - a))
|
||||||
|
.round()
|
||||||
|
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
|
||||||
|
if scratch.len() < scratch.capacity() {
|
||||||
|
scratch.push(value);
|
||||||
|
} else {
|
||||||
|
dropped = true;
|
||||||
|
}
|
||||||
|
pos += ratio;
|
||||||
|
}
|
||||||
|
*resample_pos = pos - samples.len() as f64;
|
||||||
|
*resample_last = *samples.last().unwrap_or(resample_last);
|
||||||
|
CaptureResampleResult {
|
||||||
|
output_len: scratch.len(),
|
||||||
|
dropped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod android_voice_unit_resampler_tests {
|
||||||
|
use super::resample_capture_to_48k;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn android_voice_unit_resampler_reuses_scratch_without_capacity_growth() {
|
||||||
|
let samples: Vec<i16> = (0..882).map(|i| i as i16).collect();
|
||||||
|
let mut pos = 0.0;
|
||||||
|
let mut last = 0_i16;
|
||||||
|
let mut scratch = Vec::with_capacity(960);
|
||||||
|
|
||||||
|
let first = resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
|
||||||
|
let first_len = first.output_len;
|
||||||
|
assert_eq!(first_len, 960);
|
||||||
|
assert!(!first.dropped);
|
||||||
|
assert_eq!(scratch.len(), first_len);
|
||||||
|
let warmed_capacity = scratch.capacity();
|
||||||
|
let warmed_ptr = scratch.as_ptr();
|
||||||
|
|
||||||
|
for _ in 0..8 {
|
||||||
|
let result =
|
||||||
|
resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
|
||||||
|
let len = result.output_len;
|
||||||
|
assert_eq!(len, scratch.len());
|
||||||
|
assert!(len >= 959 && len <= 960);
|
||||||
|
assert!(!result.dropped);
|
||||||
|
assert_eq!(scratch.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(scratch.as_ptr(), warmed_ptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn android_voice_unit_resampler_truncates_oversized_burst_without_capacity_growth() {
|
||||||
|
let samples: Vec<i16> = (0..4_800).map(|i| i as i16).collect();
|
||||||
|
let mut pos = 0.0;
|
||||||
|
let mut last = 0_i16;
|
||||||
|
let mut scratch = Vec::with_capacity(960);
|
||||||
|
let warmed_capacity = scratch.capacity();
|
||||||
|
let warmed_ptr = scratch.as_ptr();
|
||||||
|
|
||||||
|
let result = resample_capture_to_48k(&samples, 48_000, &mut pos, &mut last, &mut scratch);
|
||||||
|
|
||||||
|
assert_eq!(result.output_len, warmed_capacity);
|
||||||
|
assert!(result.dropped);
|
||||||
|
assert_eq!(scratch.len(), warmed_capacity);
|
||||||
|
assert_eq!(scratch.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(scratch.as_ptr(), warmed_ptr);
|
||||||
|
assert_eq!(pos, 0.0);
|
||||||
|
assert_eq!(last, *samples.last().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,515 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use cpal::traits::StreamTrait;
|
||||||
|
use cpal::{SampleFormat, SizedSample};
|
||||||
|
use tracing::{debug, error, warn, info};
|
||||||
|
|
||||||
|
use chanora_protocol::OutPacket;
|
||||||
|
|
||||||
|
use audiopus::coder::Encoder as OpusEncoder;
|
||||||
|
|
||||||
|
use crate::AudioError;
|
||||||
|
|
||||||
|
use super::SAMPLE_RATE;
|
||||||
|
use super::FRAME_SAMPLES;
|
||||||
|
|
||||||
|
const LEVEL_METER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(33);
|
||||||
|
|
||||||
|
pub(super) fn try_open_capture(
|
||||||
|
in_dev: &cpal::Device,
|
||||||
|
voice_out_tx: tokio::sync::mpsc::Sender<OutPacket>,
|
||||||
|
transmit_active: Arc<AtomicBool>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
mic_gain: f32,
|
||||||
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
|
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
) -> Result<cpal::Stream, AudioError> {
|
||||||
|
let in_cfg = in_dev
|
||||||
|
.default_input_config()
|
||||||
|
.map_err(|e| AudioError::StreamConfig(format!("input default: {e}")))?;
|
||||||
|
let in_sample_rate = in_cfg.sample_rate();
|
||||||
|
let in_channels = in_cfg.channels() as usize;
|
||||||
|
let in_format = in_cfg.sample_format();
|
||||||
|
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
in_stream_cfg.buffer_size = cpal::BufferSize::Fixed(2048);
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
in_stream_cfg.buffer_size = cpal::BufferSize::Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
let opus_enc = crate::opus_voice::new_voip_encoder("cpal capture")?;
|
||||||
|
|
||||||
|
let capture_state = Arc::new(Mutex::new(CaptureState::new(
|
||||||
|
opus_enc,
|
||||||
|
in_sample_rate,
|
||||||
|
in_channels,
|
||||||
|
mic_gain,
|
||||||
|
crate::opus_voice::start_out_packet_worker(
|
||||||
|
voice_out_tx,
|
||||||
|
frames_sent.clone(),
|
||||||
|
"cpal-capture",
|
||||||
|
)?,
|
||||||
|
transmit_active,
|
||||||
|
voice_activity_selector,
|
||||||
|
audio_processing_config,
|
||||||
|
silero_vad_worker,
|
||||||
|
audio_processing_stats,
|
||||||
|
)));
|
||||||
|
|
||||||
|
let stream = match in_format {
|
||||||
|
SampleFormat::F32 => build_input_stream::<f32>(in_dev, &in_stream_cfg, capture_state)?,
|
||||||
|
SampleFormat::I16 => build_input_stream::<i16>(in_dev, &in_stream_cfg, capture_state)?,
|
||||||
|
SampleFormat::U16 => build_input_stream::<u16>(in_dev, &in_stream_cfg, capture_state)?,
|
||||||
|
other => {
|
||||||
|
return Err(AudioError::StreamConfig(format!(
|
||||||
|
"unsupported input format: {other:?}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct CaptureState {
|
||||||
|
encoder: OpusEncoder,
|
||||||
|
pub(super) pcm_accum: Vec<f32>,
|
||||||
|
pub(super) pending_10ms: [f32; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
|
pub(super) pending_10ms_len: usize,
|
||||||
|
pub(super) capture_frame_seq: u64,
|
||||||
|
in_sample_rate: u32,
|
||||||
|
in_channels: usize,
|
||||||
|
mic_gain: f32,
|
||||||
|
resample_pos: f64,
|
||||||
|
resample_last: f32,
|
||||||
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
|
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||||
|
transmit_active: Arc<AtomicBool>,
|
||||||
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
|
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||||
|
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||||
|
silero_model_epoch: u64,
|
||||||
|
current_vad_backend: crate::VadBackend,
|
||||||
|
fallback_warned_backend: Option<crate::VadBackend>,
|
||||||
|
vad_state: crate::voice_activity::VoiceActivityStateMachine,
|
||||||
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
|
mono_scratch: Vec<f32>,
|
||||||
|
frame_scratch: Vec<f32>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
last_level_emit: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptureState {
|
||||||
|
pub(super) fn new(
|
||||||
|
encoder: OpusEncoder,
|
||||||
|
in_sample_rate: u32,
|
||||||
|
in_channels: usize,
|
||||||
|
mic_gain: f32,
|
||||||
|
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||||
|
transmit_active: Arc<AtomicBool>,
|
||||||
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
|
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
encoder,
|
||||||
|
in_sample_rate,
|
||||||
|
in_channels,
|
||||||
|
mic_gain,
|
||||||
|
pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
|
||||||
|
resample_pos: 0.0,
|
||||||
|
resample_last: 0.0,
|
||||||
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
|
voice_out_tx,
|
||||||
|
transmit_active,
|
||||||
|
voice_activity_selector,
|
||||||
|
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||||
|
silero_vad_worker,
|
||||||
|
silero_model_epoch: crate::vad::silero_model_epoch(),
|
||||||
|
current_vad_backend: crate::VadBackend::Disabled,
|
||||||
|
fallback_warned_backend: None,
|
||||||
|
capture_frame_seq: 0,
|
||||||
|
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
|
||||||
|
audio_processing_config,
|
||||||
|
pending_10ms: [0.0; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
|
pending_10ms_len: 0,
|
||||||
|
mono_scratch: Vec::with_capacity(4096),
|
||||||
|
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
|
||||||
|
audio_processing_stats,
|
||||||
|
last_level_emit: std::time::Instant::now()
|
||||||
|
.checked_sub(LEVEL_METER_INTERVAL)
|
||||||
|
.unwrap_or_else(std::time::Instant::now),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
|
||||||
|
let in_channels = self.in_channels;
|
||||||
|
let mic_gain = self.mic_gain;
|
||||||
|
self.mono_scratch.clear();
|
||||||
|
let frame_count = buf.len() / in_channels.max(1);
|
||||||
|
self.mono_scratch.reserve(frame_count);
|
||||||
|
for frame in buf.chunks(in_channels) {
|
||||||
|
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
|
||||||
|
self.mono_scratch.push(sum / frame.len() as f32);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
if now.duration_since(self.last_level_emit) >= LEVEL_METER_INTERVAL {
|
||||||
|
self.last_level_emit = now;
|
||||||
|
self.audio_processing_stats
|
||||||
|
.set_input_dbfs(crate::frame::dbfs(&self.mono_scratch));
|
||||||
|
}
|
||||||
|
|
||||||
|
if mic_gain != 1.0 {
|
||||||
|
for s in &mut self.mono_scratch {
|
||||||
|
*s *= mic_gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let vad_start_offset = self.pcm_accum.len();
|
||||||
|
if self.in_sample_rate == SAMPLE_RATE {
|
||||||
|
let (src, dst) = (&self.mono_scratch, &mut self.pcm_accum);
|
||||||
|
dst.extend_from_slice(src);
|
||||||
|
} else {
|
||||||
|
let mono = std::mem::take(&mut self.mono_scratch);
|
||||||
|
self.resample_into_accum(&mono);
|
||||||
|
self.mono_scratch = mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.process_pending_vad_frames(vad_start_offset);
|
||||||
|
|
||||||
|
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||||
|
self.pcm_accum.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while self.pcm_accum.len() >= FRAME_SAMPLES {
|
||||||
|
let frame = &mut self.frame_scratch;
|
||||||
|
frame.clear();
|
||||||
|
frame.extend(self.pcm_accum.drain(..FRAME_SAMPLES));
|
||||||
|
for s in frame.iter_mut() {
|
||||||
|
if *s > 1.0 {
|
||||||
|
*s = 1.0;
|
||||||
|
} else if *s < -1.0 {
|
||||||
|
*s = -1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match self
|
||||||
|
.encoder
|
||||||
|
.encode_float(&frame[..], &mut self.opus_out[..])
|
||||||
|
{
|
||||||
|
Ok(len) => {
|
||||||
|
crate::opus_voice::send_voip_frame(
|
||||||
|
&self.voice_out_tx,
|
||||||
|
&self.opus_out,
|
||||||
|
len,
|
||||||
|
|| {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
"voice_out queue full; dropping frame"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|| {
|
||||||
|
warn!(target: "chanora_audio", "voice_out closed; stopping send");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(target: "chanora_audio", error = %e, "opus encode failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn process_pending_vad_frames(&mut self, start_offset: usize) {
|
||||||
|
let mut offset = start_offset.min(self.pcm_accum.len());
|
||||||
|
while offset < self.pcm_accum.len() {
|
||||||
|
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
|
||||||
|
let take = remaining.min(self.pcm_accum.len() - offset);
|
||||||
|
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
|
||||||
|
.copy_from_slice(&self.pcm_accum[offset..offset + take]);
|
||||||
|
self.pending_10ms_len += take;
|
||||||
|
offset += take;
|
||||||
|
|
||||||
|
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||||
|
let frame = self.pending_10ms;
|
||||||
|
self.process_10ms_capture_frame(&frame);
|
||||||
|
self.pending_10ms_len = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
|
||||||
|
self.fallback_warned_backend = Some(failed_backend);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_vad_backend(&mut self, voice_activity_mode: bool, vad_backend: crate::VadBackend) {
|
||||||
|
if !voice_activity_mode {
|
||||||
|
self.current_vad_backend = crate::VadBackend::Disabled;
|
||||||
|
self.fallback_warned_backend = None;
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let silero_epoch = crate::vad::silero_model_epoch();
|
||||||
|
let silero_changed =
|
||||||
|
vad_backend == crate::VadBackend::SileroOnnx && silero_epoch != self.silero_model_epoch;
|
||||||
|
if vad_backend == self.current_vad_backend && !silero_changed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_vad_backend = vad_backend;
|
||||||
|
self.silero_model_epoch = silero_epoch;
|
||||||
|
self.fallback_warned_backend = None;
|
||||||
|
self.vad_state.reset();
|
||||||
|
|
||||||
|
match vad_backend {
|
||||||
|
crate::VadBackend::SileroOnnx => {
|
||||||
|
let worker_available = self
|
||||||
|
.silero_vad_worker
|
||||||
|
.try_lock()
|
||||||
|
.map(|worker| worker.is_some())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if worker_available {
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
|
} else {
|
||||||
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::VadBackend::WebrtcVad => {
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
|
}
|
||||||
|
crate::VadBackend::EnergyDebug => {
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||||
|
}
|
||||||
|
crate::VadBackend::Disabled => {
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn process_10ms_capture_frame(&mut self, frame: &[f32; crate::frame::FRAME_10MS_SAMPLES]) {
|
||||||
|
let input_dbfs = crate::frame::dbfs(frame);
|
||||||
|
let (vad_backend, vad_hangover) = self
|
||||||
|
.audio_processing_config
|
||||||
|
.try_lock()
|
||||||
|
.map(|cfg| (cfg.vad_backend, cfg.vad_hangover_ms))
|
||||||
|
.unwrap_or((
|
||||||
|
crate::VadBackend::WebrtcVad,
|
||||||
|
crate::voice_activity::VAD_HANGOVER_MS,
|
||||||
|
));
|
||||||
|
let voice_activity_mode = self
|
||||||
|
.voice_activity_selector
|
||||||
|
.as_ref()
|
||||||
|
.map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if voice_activity_mode {
|
||||||
|
self.sync_vad_backend(true, vad_backend);
|
||||||
|
self.vad_state.configure(
|
||||||
|
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
||||||
|
vad_hangover,
|
||||||
|
crate::voice_activity::VAD_MIN_TX_MS,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self.sync_vad_backend(false, vad_backend);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (vad_probability, gate_open, used_fallback_vad) = if voice_activity_mode {
|
||||||
|
self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
|
||||||
|
let capture_seq = self.capture_frame_seq;
|
||||||
|
let mut used_fallback_vad = false;
|
||||||
|
let vad = match vad_backend {
|
||||||
|
crate::VadBackend::Disabled => crate::vad::VadOutput {
|
||||||
|
probability: 1.0,
|
||||||
|
speech: true,
|
||||||
|
},
|
||||||
|
crate::VadBackend::SileroOnnx => {
|
||||||
|
let worker_output = {
|
||||||
|
let guard = self.silero_vad_worker.try_lock().ok();
|
||||||
|
guard.and_then(|guard| {
|
||||||
|
let worker = guard.as_ref()?;
|
||||||
|
if worker.try_send(capture_seq, frame) && !worker.is_stale(capture_seq)
|
||||||
|
{
|
||||||
|
let p = worker.latest_probability();
|
||||||
|
Some(crate::vad::VadOutput {
|
||||||
|
probability: p,
|
||||||
|
speech: p >= 0.5,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if let Some(output) = worker_output {
|
||||||
|
output
|
||||||
|
} else {
|
||||||
|
used_fallback_vad = true;
|
||||||
|
self.mark_vad_fallback_active(vad_backend);
|
||||||
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
|
&mut self.vad_detector,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::VadBackend::WebrtcVad | crate::VadBackend::EnergyDebug => {
|
||||||
|
used_fallback_vad = vad_backend == crate::VadBackend::EnergyDebug;
|
||||||
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, frame)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(
|
||||||
|
vad.probability,
|
||||||
|
self.vad_state.update(vad.speech),
|
||||||
|
used_fallback_vad,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(0.0, false, false)
|
||||||
|
};
|
||||||
|
self.audio_processing_stats
|
||||||
|
.set_vad_fallback_active(used_fallback_vad);
|
||||||
|
|
||||||
|
let vad_active = voice_activity_mode && gate_open;
|
||||||
|
if let Some(selector) = &self.voice_activity_selector {
|
||||||
|
selector.set_voice_activity_open(vad_active);
|
||||||
|
}
|
||||||
|
self.audio_processing_stats.update_capture(
|
||||||
|
input_dbfs,
|
||||||
|
input_dbfs,
|
||||||
|
vad_probability,
|
||||||
|
vad_active,
|
||||||
|
self.transmit_active.load(Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
self.audio_processing_stats
|
||||||
|
.record_capture_frame(frame.iter().all(|sample| sample.abs() <= 0.000_001));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resample_into_accum(&mut self, mono: &[f32]) {
|
||||||
|
if mono.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ratio = self.in_sample_rate as f64 / SAMPLE_RATE as f64;
|
||||||
|
let mut pos = self.resample_pos;
|
||||||
|
while pos < mono.len() as f64 {
|
||||||
|
let i = pos.floor() as isize;
|
||||||
|
let frac = pos - i as f64;
|
||||||
|
let a = if i <= 0 {
|
||||||
|
self.resample_last
|
||||||
|
} else {
|
||||||
|
mono[(i - 1) as usize]
|
||||||
|
};
|
||||||
|
let b = if i < mono.len() as isize {
|
||||||
|
mono[i as usize]
|
||||||
|
} else {
|
||||||
|
a
|
||||||
|
};
|
||||||
|
self.pcm_accum
|
||||||
|
.push((a as f64 + frac * (b - a) as f64) as f32);
|
||||||
|
pos += ratio;
|
||||||
|
}
|
||||||
|
self.resample_pos = pos - mono.len() as f64;
|
||||||
|
self.resample_last = *mono.last().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait ToF32 {
|
||||||
|
fn to_f32_sample(self) -> f32;
|
||||||
|
}
|
||||||
|
impl ToF32 for f32 {
|
||||||
|
fn to_f32_sample(self) -> f32 {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ToF32 for i16 {
|
||||||
|
fn to_f32_sample(self) -> f32 {
|
||||||
|
f32::from(self) / f32::from(i16::MAX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ToF32 for u16 {
|
||||||
|
fn to_f32_sample(self) -> f32 {
|
||||||
|
(f32::from(self) - f32::from(i16::MAX) - 1.0) / f32::from(i16::MAX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_input_stream<T>(
|
||||||
|
device: &cpal::Device,
|
||||||
|
config: &cpal::StreamConfig,
|
||||||
|
state: Arc<Mutex<CaptureState>>,
|
||||||
|
) -> Result<cpal::Stream, AudioError>
|
||||||
|
where
|
||||||
|
T: SizedSample + ToF32 + Send + 'static,
|
||||||
|
{
|
||||||
|
let stream = device
|
||||||
|
.build_input_stream(
|
||||||
|
*config,
|
||||||
|
move |data: &[T], _: &cpal::InputCallbackInfo| {
|
||||||
|
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
s.ingest(data);
|
||||||
|
},
|
||||||
|
move |e| {
|
||||||
|
error!(target: "chanora_audio", error = %e, "input stream error");
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| AudioError::Backend(format!("build_input_stream: {e}")))?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub mod bench_seam {
|
||||||
|
use super::{Arc, AtomicBool, AtomicU32, CaptureState, OutPacket};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
pub struct CaptureBenchHandle {
|
||||||
|
pub(crate) state: CaptureState,
|
||||||
|
_rx: mpsc::Receiver<OutPacket>,
|
||||||
|
transmit_active: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptureBenchHandle {
|
||||||
|
pub fn new(in_sample_rate: u32, in_channels: usize) -> Self {
|
||||||
|
let encoder =
|
||||||
|
crate::opus_voice::new_voip_encoder("cpal bench").expect("opus encoder init");
|
||||||
|
let (tx, rx) = mpsc::channel::<OutPacket>(64);
|
||||||
|
let transmit_active = Arc::new(AtomicBool::new(true));
|
||||||
|
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||||
|
let voice_out_tx =
|
||||||
|
crate::opus_voice::start_out_packet_worker(tx, frames_sent, "cpal-bench")
|
||||||
|
.expect("start_out_packet_worker");
|
||||||
|
let state = CaptureState::new(
|
||||||
|
encoder,
|
||||||
|
in_sample_rate,
|
||||||
|
in_channels,
|
||||||
|
1.0,
|
||||||
|
voice_out_tx,
|
||||||
|
transmit_active.clone(),
|
||||||
|
None,
|
||||||
|
Arc::new(std::sync::Mutex::new(
|
||||||
|
crate::AudioProcessingConfig::default(),
|
||||||
|
)),
|
||||||
|
Arc::new(std::sync::Mutex::new(None)),
|
||||||
|
Arc::new(crate::SharedAudioProcessingStats::default()),
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
state,
|
||||||
|
_rx: rx,
|
||||||
|
transmit_active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn ingest_f32(&mut self, buf: &[f32]) {
|
||||||
|
self.state.ingest(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_transmit_active(&self, active: bool) {
|
||||||
|
self.transmit_active
|
||||||
|
.store(active, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use cpal::traits::StreamTrait;
|
||||||
|
use cpal::SampleFormat;
|
||||||
|
use tracing::{error, warn};
|
||||||
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
|
||||||
|
use crate::AudioError;
|
||||||
|
|
||||||
|
use super::SessionAudioId;
|
||||||
|
use super::SAMPLE_RATE;
|
||||||
|
|
||||||
|
pub(super) fn build_output_stream<T>(
|
||||||
|
device: &cpal::Device,
|
||||||
|
config: &cpal::StreamConfig,
|
||||||
|
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||||
|
output_gain: Arc<AtomicU32>,
|
||||||
|
output_muted: Arc<AtomicBool>,
|
||||||
|
dev_sample_rate: u32,
|
||||||
|
dev_channels: usize,
|
||||||
|
) -> Result<cpal::Stream, AudioError>
|
||||||
|
where
|
||||||
|
T: cpal::SizedSample + FromF32 + Send + 'static,
|
||||||
|
{
|
||||||
|
let resample_ratio = SAMPLE_RATE as f64 / dev_sample_rate as f64;
|
||||||
|
let same_rate = dev_sample_rate == SAMPLE_RATE;
|
||||||
|
let resample_state: Arc<Mutex<PlaybackResampleState>> =
|
||||||
|
Arc::new(Mutex::new(PlaybackResampleState {
|
||||||
|
pos: 0.0,
|
||||||
|
last_l: 0.0,
|
||||||
|
last_r: 0.0,
|
||||||
|
}));
|
||||||
|
let mut scratch: Vec<f32> = Vec::with_capacity(8192);
|
||||||
|
let mut last_slow_warn = std::time::Instant::now()
|
||||||
|
.checked_sub(std::time::Duration::from_secs(2))
|
||||||
|
.unwrap_or_else(std::time::Instant::now);
|
||||||
|
let stream = device
|
||||||
|
.build_output_stream(
|
||||||
|
*config,
|
||||||
|
move |out: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||||
|
let cb_start = std::time::Instant::now();
|
||||||
|
let muted = output_muted.load(Ordering::Relaxed);
|
||||||
|
let dev_frames = out.len() / dev_channels.max(1);
|
||||||
|
let src_frames = if same_rate {
|
||||||
|
dev_frames
|
||||||
|
} else {
|
||||||
|
((dev_frames as f64 * resample_ratio).ceil() as usize) + 2
|
||||||
|
};
|
||||||
|
let needed = src_frames * 2;
|
||||||
|
if scratch.len() < needed {
|
||||||
|
scratch.resize(needed, 0.0);
|
||||||
|
}
|
||||||
|
scratch[..needed].fill(0.0);
|
||||||
|
{
|
||||||
|
let mut h = handler.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
h.fill_buffer(&mut scratch[..needed]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if muted {
|
||||||
|
for dst in out.iter_mut() {
|
||||||
|
*dst = T::from_f32_sample(0.0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
||||||
|
if same_rate && dev_channels == 2 {
|
||||||
|
for (dst, s) in out.iter_mut().zip(scratch[..needed].iter().copied()) {
|
||||||
|
*dst = T::from_f32_sample(s * gain);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut state = resample_state.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let mut pos = state.pos;
|
||||||
|
let mut last_l = state.last_l;
|
||||||
|
let mut last_r = state.last_r;
|
||||||
|
for frame_idx in 0..dev_frames {
|
||||||
|
let i = pos.floor() as isize;
|
||||||
|
let frac = pos - i as f64;
|
||||||
|
let (a_l, a_r) = if i <= 0 {
|
||||||
|
(last_l, last_r)
|
||||||
|
} else {
|
||||||
|
let idx = ((i - 1) as usize) * 2;
|
||||||
|
(scratch[idx], scratch[idx + 1])
|
||||||
|
};
|
||||||
|
let i_usize = i.max(0) as usize;
|
||||||
|
let (b_l, b_r) = if i_usize < src_frames {
|
||||||
|
let idx = i_usize * 2;
|
||||||
|
(scratch[idx], scratch[idx + 1])
|
||||||
|
} else {
|
||||||
|
(a_l, a_r)
|
||||||
|
};
|
||||||
|
let l = (a_l as f64 + frac * (b_l - a_l) as f64) as f32 * gain;
|
||||||
|
let r = (a_r as f64 + frac * (b_r - a_r) as f64) as f32 * gain;
|
||||||
|
let base = frame_idx * dev_channels;
|
||||||
|
if dev_channels == 1 {
|
||||||
|
out[base] = T::from_f32_sample((l + r) * 0.5);
|
||||||
|
} else {
|
||||||
|
out[base] = T::from_f32_sample(l);
|
||||||
|
if dev_channels >= 2 {
|
||||||
|
out[base + 1] = T::from_f32_sample(r);
|
||||||
|
}
|
||||||
|
for c in 2..dev_channels {
|
||||||
|
out[base + c] = T::from_f32_sample(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos += resample_ratio;
|
||||||
|
}
|
||||||
|
let consumed = pos.floor() as usize;
|
||||||
|
state.pos = pos - consumed as f64;
|
||||||
|
if consumed > 0 && consumed <= src_frames {
|
||||||
|
let idx = (consumed - 1) * 2;
|
||||||
|
last_l = scratch[idx];
|
||||||
|
last_r = scratch[idx + 1];
|
||||||
|
state.last_l = last_l;
|
||||||
|
state.last_r = last_r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = cb_start.elapsed();
|
||||||
|
let period_us = (dev_frames as u64 * 1_000_000) / dev_sample_rate as u64;
|
||||||
|
if elapsed.as_micros() as u64 > period_us / 2
|
||||||
|
&& last_slow_warn.elapsed() > std::time::Duration::from_secs(1)
|
||||||
|
{
|
||||||
|
last_slow_warn = std::time::Instant::now();
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
callback_us = elapsed.as_micros() as u64,
|
||||||
|
period_us,
|
||||||
|
dev_frames,
|
||||||
|
"output callback exceeded half the period budget — possible underrun cause"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move |e| {
|
||||||
|
error!(target: "chanora_audio", error = %e, "output stream error");
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
error!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
error = %e,
|
||||||
|
requested_channels = config.channels,
|
||||||
|
requested_sample_rate = config.sample_rate,
|
||||||
|
"build_output_stream FAILED"
|
||||||
|
);
|
||||||
|
AudioError::Backend(format!("build_output_stream: {e}"))
|
||||||
|
})?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaybackResampleState {
|
||||||
|
pos: f64,
|
||||||
|
last_l: f32,
|
||||||
|
last_r: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) trait FromF32 {
|
||||||
|
fn from_f32_sample(v: f32) -> Self;
|
||||||
|
}
|
||||||
|
impl FromF32 for f32 {
|
||||||
|
fn from_f32_sample(v: f32) -> Self {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromF32 for i16 {
|
||||||
|
fn from_f32_sample(v: f32) -> Self {
|
||||||
|
(v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromF32 for u16 {
|
||||||
|
fn from_f32_sample(v: f32) -> Self {
|
||||||
|
let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32;
|
||||||
|
(s + i32::from(i16::MAX) + 1) as u16
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,56 +16,12 @@ pub const FRAME_10MS_SAMPLES: usize = 480;
|
|||||||
/// Samples in one 20 ms mono frame at 48 kHz.
|
/// Samples in one 20 ms mono frame at 48 kHz.
|
||||||
pub const FRAME_20MS_SAMPLES: usize = 960;
|
pub const FRAME_20MS_SAMPLES: usize = 960;
|
||||||
|
|
||||||
/// 10 ms, 48 kHz, mono f32 processing frame.
|
/// Convert i16 PCM sample to normalized f32 PCM (-1.0 to 1.0).
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct AudioFrame10ms {
|
|
||||||
/// Samples normalized to `[-1.0, 1.0]`.
|
|
||||||
pub samples: [f32; FRAME_10MS_SAMPLES],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 20 ms, 48 kHz, mono f32 network-frame-sized buffer.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct AudioFrame20ms {
|
|
||||||
/// Samples normalized to `[-1.0, 1.0]`.
|
|
||||||
pub samples: [f32; FRAME_20MS_SAMPLES],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioFrame20ms {
|
|
||||||
/// Convert one 20 ms frame into two 10 ms processing frames.
|
|
||||||
pub fn split(&self) -> (AudioFrame10ms, AudioFrame10ms) {
|
|
||||||
let mut first = [0.0; FRAME_10MS_SAMPLES];
|
|
||||||
let mut second = [0.0; FRAME_10MS_SAMPLES];
|
|
||||||
first.copy_from_slice(&self.samples[..FRAME_10MS_SAMPLES]);
|
|
||||||
second.copy_from_slice(&self.samples[FRAME_10MS_SAMPLES..]);
|
|
||||||
(
|
|
||||||
AudioFrame10ms { samples: first },
|
|
||||||
AudioFrame10ms { samples: second },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioFrame10ms {
|
|
||||||
/// Merge two 10 ms processing frames back into the 20 ms network
|
|
||||||
/// cadence used by the existing Opus path.
|
|
||||||
pub fn merge(first: &Self, second: &Self) -> AudioFrame20ms {
|
|
||||||
let mut samples = [0.0; FRAME_20MS_SAMPLES];
|
|
||||||
samples[..FRAME_10MS_SAMPLES].copy_from_slice(&first.samples);
|
|
||||||
samples[FRAME_10MS_SAMPLES..].copy_from_slice(&second.samples);
|
|
||||||
AudioFrame20ms { samples }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute RMS dBFS for diagnostics and fallback VAD.
|
|
||||||
pub fn dbfs(&self) -> f32 {
|
|
||||||
dbfs(&self.samples)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert i16 PCM to normalized f32 PCM.
|
|
||||||
pub fn i16_to_f32(sample: i16) -> f32 {
|
pub fn i16_to_f32(sample: i16) -> f32 {
|
||||||
sample as f32 / i16::MAX as f32
|
sample as f32 / i16::MAX as f32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert normalized f32 PCM to saturated i16 PCM.
|
/// Convert normalized f32 PCM to saturated i16 PCM (clamps to [-1.0, 1.0]).
|
||||||
pub fn f32_to_i16(sample: f32) -> i16 {
|
pub fn f32_to_i16(sample: f32) -> i16 {
|
||||||
(sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
|
(sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
|
||||||
}
|
}
|
||||||
@@ -84,18 +40,4 @@ pub fn dbfs(samples: &[f32]) -> f32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_merge_preserves_samples() {
|
|
||||||
let mut samples = [0.0; FRAME_20MS_SAMPLES];
|
|
||||||
for (i, s) in samples.iter_mut().enumerate() {
|
|
||||||
*s = i as f32 / FRAME_20MS_SAMPLES as f32;
|
|
||||||
}
|
|
||||||
let original = AudioFrame20ms { samples };
|
|
||||||
let (a, b) = original.split();
|
|
||||||
assert_eq!(AudioFrame10ms::merge(&a, &b), original);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,558 +0,0 @@
|
|||||||
//! Optional raw iOS RemoteIO path for the WebRTC APM experimental mode.
|
|
||||||
//!
|
|
||||||
//! Provides an alternative to `ios_voice_unit.rs` for the
|
|
||||||
//! `SonoraExperimental` processing mode. Instead of
|
|
||||||
//! `kAudioUnitSubType_VoiceProcessingIO` (which owns AEC/NS/AGC), it
|
|
||||||
//! opens `kAudioUnitSubType_RemoteIO` with voice processing explicitly
|
|
||||||
//! disabled so WebRTC APM can own the full signal path.
|
|
||||||
//!
|
|
||||||
//! ## Hard invariants enforced here
|
|
||||||
//!
|
|
||||||
//! * INV_009: Rust AEC only active when platform AEC is disabled.
|
|
||||||
//! * INV_010: VoiceProcessingIO and WebRTC APM AEC are mutually exclusive.
|
|
||||||
//! * INV_011: Software AEC backend receives both capture and render-reference.
|
|
||||||
//! * INV_012: Render reference is copied from decoded/mixed remote PCM
|
|
||||||
//! before playout.
|
|
||||||
//!
|
|
||||||
//! ## Fallback
|
|
||||||
//!
|
|
||||||
//! If RemoteIO construction fails, the caller falls back to `IosVoiceUnit`
|
|
||||||
//! (VPIO) and logs the error.
|
|
||||||
//!
|
|
||||||
//! ## Status
|
|
||||||
//!
|
|
||||||
//! Experimental / disabled by default. Only activated when the user
|
|
||||||
//! explicitly selects `SonoraExperimental` mode via the bridge API.
|
|
||||||
//!
|
|
||||||
//! ## Platform
|
|
||||||
//!
|
|
||||||
//! `kAudioUnitSubType_RemoteIO` is only available in the iOS SDK.
|
|
||||||
//! This module is gated to `target_os = "ios"`.
|
|
||||||
|
|
||||||
#[cfg(target_os = "ios")]
|
|
||||||
pub use inner::IosRawUnit;
|
|
||||||
|
|
||||||
#[cfg(target_os = "ios")]
|
|
||||||
mod inner {
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use audiopus::coder::Encoder as OpusEncoder;
|
|
||||||
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
|
||||||
use coreaudio::audio_unit::render_callback::{self, data};
|
|
||||||
use coreaudio::audio_unit::IOType;
|
|
||||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tracing::{info, warn};
|
|
||||||
|
|
||||||
use crate::mobile_voice_backend::VoiceAudioParams;
|
|
||||||
use crate::processor::AudioProcessor;
|
|
||||||
use crate::AudioError;
|
|
||||||
use chanora_protocol::OutPacket;
|
|
||||||
|
|
||||||
const SAMPLE_RATE_HZ: f64 = 48_000.0;
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
// Render-reference ring buffer //
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
|
|
||||||
/// 4-slot ring buffer shared between the render callback (writer) and
|
|
||||||
/// the capture callback (reader for Sonora AEC3). Capacity: 4 × 10 ms
|
|
||||||
/// = 40 ms of headroom.
|
|
||||||
///
|
|
||||||
/// If the capture callback runs before the render callback has written
|
|
||||||
/// a frame it reads zeros (silence reference), which is safe — Sonora
|
|
||||||
/// AEC3 simply skips cancellation for that frame.
|
|
||||||
struct RenderReferenceBuffer {
|
|
||||||
buf: Box<[[f32; 480]; 4]>,
|
|
||||||
write_idx: std::sync::atomic::AtomicUsize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderReferenceBuffer {
|
|
||||||
fn new() -> Arc<Self> {
|
|
||||||
Arc::new(Self {
|
|
||||||
buf: Box::new([[0.0; 480]; 4]),
|
|
||||||
write_idx: std::sync::atomic::AtomicUsize::new(0),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write one 10 ms render-reference frame. Realtime-safe.
|
|
||||||
fn write(&self, frame: &[f32; 480]) {
|
|
||||||
let idx = self.write_idx.load(Ordering::Relaxed);
|
|
||||||
// SAFETY: only one writer (render callback); torn reads
|
|
||||||
// are bounded to one frame of AEC degradation.
|
|
||||||
unsafe {
|
|
||||||
let slot = &self.buf[idx] as *const [f32; 480] as *mut [f32; 480];
|
|
||||||
(*slot).copy_from_slice(frame);
|
|
||||||
}
|
|
||||||
self.write_idx.store((idx + 1) % 4, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the most recently completed render-reference frame.
|
|
||||||
fn read_latest(&self) -> [f32; 480] {
|
|
||||||
let wi = self.write_idx.load(Ordering::Relaxed);
|
|
||||||
let ri = (wi + 3) % 4;
|
|
||||||
self.buf[ri]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: accessed from two audio callback threads; data races are
|
|
||||||
// bounded to one frame of AEC quality degradation.
|
|
||||||
unsafe impl Send for RenderReferenceBuffer {}
|
|
||||||
unsafe impl Sync for RenderReferenceBuffer {}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
// Capture pipeline state //
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
|
|
||||||
struct RawCaptureState {
|
|
||||||
encoder: OpusEncoder,
|
|
||||||
pcm_accum: Vec<i16>,
|
|
||||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
|
||||||
transmit_active: Arc<AtomicBool>,
|
|
||||||
output_muted: Arc<AtomicBool>,
|
|
||||||
frames_sent: Arc<AtomicU32>,
|
|
||||||
mic_gain: f32,
|
|
||||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
|
||||||
silero_coreml_worker: Option<crate::vad::apple_coreml::AppleCoreMlVadWorker>,
|
|
||||||
current_vad_backend: crate::VadBackend,
|
|
||||||
capture_frame_seq: u64,
|
|
||||||
vad_state: crate::voice_activity::VoiceActivityStateMachine,
|
|
||||||
/// Processing config — retained for route-change reloads.
|
|
||||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
|
||||||
webrtc_apm_processor: crate::processor::WebRtcApmProcessor,
|
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
|
||||||
render_reference: Arc<RenderReferenceBuffer>,
|
|
||||||
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
|
|
||||||
pending_10ms_len: usize,
|
|
||||||
fallback_warned_backend: Option<crate::VadBackend>,
|
|
||||||
wav_recorder: Option<Arc<crate::debug_wav::WavDebugRecorder>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RawCaptureState {
|
|
||||||
fn new(
|
|
||||||
params: &VoiceAudioParams,
|
|
||||||
render_reference: Arc<RenderReferenceBuffer>,
|
|
||||||
) -> Result<Self, AudioError> {
|
|
||||||
let encoder = crate::opus_voice::new_voip_encoder("ios raw")?;
|
|
||||||
let webrtc_apm_config = params
|
|
||||||
.audio_processing_config
|
|
||||||
.lock()
|
|
||||||
.map(|cfg| crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(&cfg))
|
|
||||||
.unwrap_or_default();
|
|
||||||
Ok(Self {
|
|
||||||
encoder,
|
|
||||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
|
||||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
|
||||||
voice_out_tx: params.voice_out_tx.clone(),
|
|
||||||
transmit_active: params.transmit_active.clone(),
|
|
||||||
output_muted: params.output_muted.clone(),
|
|
||||||
frames_sent: params.frames_sent.clone(),
|
|
||||||
mic_gain: params.mic_gain,
|
|
||||||
voice_activity_selector: params.voice_activity_selector.clone(),
|
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
|
||||||
silero_coreml_worker: None,
|
|
||||||
current_vad_backend: crate::VadBackend::WebrtcVad,
|
|
||||||
capture_frame_seq: 0,
|
|
||||||
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
|
|
||||||
audio_processing_config: params.audio_processing_config.clone(),
|
|
||||||
webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config(
|
|
||||||
webrtc_apm_config,
|
|
||||||
)?,
|
|
||||||
audio_processing_stats: params.audio_processing_stats.clone(),
|
|
||||||
render_reference,
|
|
||||||
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
|
||||||
pending_10ms_len: 0,
|
|
||||||
fallback_warned_backend: None,
|
|
||||||
wav_recorder: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
|
|
||||||
if self.fallback_warned_backend != Some(failed_backend) {
|
|
||||||
self.fallback_warned_backend = Some(failed_backend);
|
|
||||||
if self.capture_frame_seq < 128 {
|
|
||||||
tracing::info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
backend = failed_backend.as_str(),
|
|
||||||
seq = self.capture_frame_seq,
|
|
||||||
"VAD backend warming up; using WebRTC fallback"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
backend = failed_backend.as_str(),
|
|
||||||
"VAD backend unavailable; using WebRTC fallback for runtime detection"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ingest_i16(&mut self, samples: &[i16]) {
|
|
||||||
// Accumulate into 10 ms frames for VAD / Sonora processing.
|
|
||||||
let mut offset = 0;
|
|
||||||
while offset < samples.len() {
|
|
||||||
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
|
|
||||||
let take = remaining.min(samples.len() - offset);
|
|
||||||
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
|
|
||||||
.copy_from_slice(&samples[offset..offset + take]);
|
|
||||||
self.pending_10ms_len += take;
|
|
||||||
offset += take;
|
|
||||||
|
|
||||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
|
||||||
let frame = self.pending_10ms;
|
|
||||||
self.process_10ms_capture_frame(&frame);
|
|
||||||
self.pending_10ms_len = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
|
||||||
self.pcm_accum.clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode complete 20 ms Opus frames.
|
|
||||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
|
||||||
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
|
||||||
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
|
||||||
self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
|
|
||||||
|
|
||||||
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
|
|
||||||
Ok(len) => {
|
|
||||||
crate::opus_voice::send_voip_frame(
|
|
||||||
&self.voice_out_tx,
|
|
||||||
&self.frames_sent,
|
|
||||||
&self.opus_out,
|
|
||||||
len,
|
|
||||||
|| {
|
|
||||||
warn!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
"ios raw: voice_out queue full; dropping frame"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|| {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(target: "chanora_audio",
|
|
||||||
error = %e, "ios raw opus encode failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_10ms_capture_frame(
|
|
||||||
&mut self,
|
|
||||||
samples: &[i16; crate::frame::FRAME_10MS_SAMPLES],
|
|
||||||
) {
|
|
||||||
let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
|
||||||
for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
|
|
||||||
*dst = crate::frame::i16_to_f32(src);
|
|
||||||
}
|
|
||||||
let input_dbfs = crate::frame::dbfs(&frame);
|
|
||||||
|
|
||||||
// WAV tap: raw mic (before processing).
|
|
||||||
if let Some(ref rec) = self.wav_recorder {
|
|
||||||
rec.push_raw_mic(&frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Feed render reference to WebRTC APM before capture so AEC can adapt.
|
|
||||||
let render_ref = self.render_reference.read_latest();
|
|
||||||
self.webrtc_apm_processor.process_render(&render_ref);
|
|
||||||
self.webrtc_apm_processor.process_capture(&mut frame);
|
|
||||||
|
|
||||||
// WAV tap: processed mic (after WebRTC APM).
|
|
||||||
if let Some(ref rec) = self.wav_recorder {
|
|
||||||
rec.push_processed_mic(&frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
let voice_activity_mode = self
|
|
||||||
.voice_activity_selector
|
|
||||||
.as_ref()
|
|
||||||
.map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !voice_activity_mode {
|
|
||||||
self.silero_coreml_worker = None;
|
|
||||||
self.current_vad_backend = crate::VadBackend::Disabled;
|
|
||||||
self.fallback_warned_backend = None;
|
|
||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (vad_backend, vad_hangover) = self
|
|
||||||
.audio_processing_config
|
|
||||||
.try_lock()
|
|
||||||
.map(|cfg| (cfg.vad_backend, cfg.vad_hangover_ms))
|
|
||||||
.unwrap_or((
|
|
||||||
crate::VadBackend::WebrtcVad,
|
|
||||||
crate::voice_activity::VAD_HANGOVER_MS,
|
|
||||||
));
|
|
||||||
if voice_activity_mode {
|
|
||||||
self.vad_state.configure(
|
|
||||||
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
|
||||||
vad_hangover,
|
|
||||||
crate::voice_activity::VAD_MIN_TX_MS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if voice_activity_mode && vad_backend != self.current_vad_backend {
|
|
||||||
self.current_vad_backend = vad_backend;
|
|
||||||
self.fallback_warned_backend = None;
|
|
||||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
|
||||||
self.silero_coreml_worker =
|
|
||||||
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
|
|
||||||
if self.silero_coreml_worker.is_none() {
|
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
|
||||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
|
||||||
} else {
|
|
||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.silero_coreml_worker = None;
|
|
||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
|
||||||
}
|
|
||||||
self.vad_state.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
let (vad_probability, active) = if voice_activity_mode {
|
|
||||||
self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
|
|
||||||
let capture_seq = self.capture_frame_seq;
|
|
||||||
let mut used_fallback_vad = false;
|
|
||||||
let vad = if vad_backend == crate::VadBackend::Disabled {
|
|
||||||
crate::vad::VadOutput {
|
|
||||||
probability: 1.0,
|
|
||||||
speech: true,
|
|
||||||
}
|
|
||||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
|
||||||
if let Some(worker) = self.silero_coreml_worker.as_ref() {
|
|
||||||
let enqueued = worker.try_send(capture_seq, &frame);
|
|
||||||
if !worker.is_stale(capture_seq) {
|
|
||||||
let p = worker.latest_probability();
|
|
||||||
crate::vad::VadOutput {
|
|
||||||
probability: p,
|
|
||||||
speech: p >= 0.5,
|
|
||||||
}
|
|
||||||
} else if enqueued {
|
|
||||||
crate::vad::VadOutput {
|
|
||||||
probability: 0.0,
|
|
||||||
speech: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
used_fallback_vad = true;
|
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
|
||||||
&mut self.vad_detector,
|
|
||||||
&frame,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
used_fallback_vad = true;
|
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
|
||||||
&mut self.vad_detector,
|
|
||||||
&frame,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
|
||||||
};
|
|
||||||
self.audio_processing_stats
|
|
||||||
.set_vad_fallback_active(used_fallback_vad);
|
|
||||||
(vad.probability, self.vad_state.update(vad.speech))
|
|
||||||
} else {
|
|
||||||
(0.0, false)
|
|
||||||
};
|
|
||||||
if let Some(sel) = &self.voice_activity_selector {
|
|
||||||
sel.set_voice_activity_open(voice_activity_mode && active);
|
|
||||||
}
|
|
||||||
self.audio_processing_stats.update_capture(
|
|
||||||
input_dbfs,
|
|
||||||
crate::frame::dbfs(&frame),
|
|
||||||
vad_probability,
|
|
||||||
voice_activity_mode && active,
|
|
||||||
self.transmit_active.load(Ordering::Relaxed),
|
|
||||||
);
|
|
||||||
|
|
||||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let gain = self.mic_gain;
|
|
||||||
if (gain - 1.0).abs() < f32::EPSILON {
|
|
||||||
self.pcm_accum
|
|
||||||
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
|
||||||
} else {
|
|
||||||
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
|
||||||
let scaled = crate::frame::f32_to_i16(s) as f32 * gain;
|
|
||||||
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
// IosRawUnit //
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
|
|
||||||
/// Raw iOS RemoteIO audio unit for the Sonora experimental path.
|
|
||||||
pub struct IosRawUnit {
|
|
||||||
unit: AudioUnit,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IosRawUnit {
|
|
||||||
/// Open a RemoteIO AudioUnit, install render + input callbacks, start.
|
|
||||||
pub(crate) fn start(params: VoiceAudioParams) -> Result<Self, AudioError> {
|
|
||||||
// INV_010: reject if config requests VPIO (that's IosVoiceUnit's job).
|
|
||||||
{
|
|
||||||
let cfg = params.audio_processing_config.lock().unwrap();
|
|
||||||
if cfg.ios_mode == crate::IosVoiceProcessingMode::PlatformVoiceProcessing {
|
|
||||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
|
||||||
"IosRawUnit requires raw WebRTC APM mode".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut unit = AudioUnit::new_uninitialized(IOType::RemoteIO)
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio new: {e}")))?;
|
|
||||||
|
|
||||||
// Enable input on bus 1.
|
|
||||||
const ENABLE_IO: u32 = 2003;
|
|
||||||
let enable: u32 = 1;
|
|
||||||
unit.set_property(ENABLE_IO, Scope::Input, Element::Input, Some(&enable))
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio enable input: {e}")))?;
|
|
||||||
|
|
||||||
// 48 kHz Int16 mono on both buses.
|
|
||||||
let fmt = StreamFormat {
|
|
||||||
sample_rate: SAMPLE_RATE_HZ,
|
|
||||||
sample_format: SampleFormat::I16,
|
|
||||||
flags: LinearPcmFlags::IS_SIGNED_INTEGER | LinearPcmFlags::IS_PACKED,
|
|
||||||
channels: 1,
|
|
||||||
};
|
|
||||||
unit.set_stream_format(fmt, Scope::Input, Element::Output)
|
|
||||||
.map_err(|e| AudioError::StreamConfig(format!("remoteio fmt output: {e}")))?;
|
|
||||||
unit.set_stream_format(fmt, Scope::Output, Element::Input)
|
|
||||||
.map_err(|e| AudioError::StreamConfig(format!("remoteio fmt input: {e}")))?;
|
|
||||||
|
|
||||||
// Shared render-reference buffer (INV_011 / INV_012).
|
|
||||||
let render_ref_buf = RenderReferenceBuffer::new();
|
|
||||||
let render_ref_for_capture = render_ref_buf.clone();
|
|
||||||
|
|
||||||
let mut capture_state = RawCaptureState::new(¶ms, render_ref_for_capture)?;
|
|
||||||
|
|
||||||
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
|
||||||
capture_state.ingest_i16(args.data.buffer);
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
|
|
||||||
|
|
||||||
let mut scratch: Vec<f32> = Vec::with_capacity(2048);
|
|
||||||
let handler = params.handler.clone();
|
|
||||||
let output_gain = params.output_gain.clone();
|
|
||||||
let output_muted = params.output_muted.clone();
|
|
||||||
let stats_render = params.audio_processing_stats.clone();
|
|
||||||
|
|
||||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
|
||||||
let out = args.data.buffer;
|
|
||||||
let n = out.len();
|
|
||||||
let stereo_n = n * 2;
|
|
||||||
if scratch.len() < stereo_n {
|
|
||||||
scratch.resize(stereo_n, 0.0);
|
|
||||||
}
|
|
||||||
scratch[..stereo_n].fill(0.0);
|
|
||||||
|
|
||||||
match handler.try_lock() {
|
|
||||||
Ok(mut h) => {
|
|
||||||
let _ = h.fill_buffer(&mut scratch[..stereo_n]);
|
|
||||||
}
|
|
||||||
Err(std::sync::TryLockError::WouldBlock) => {
|
|
||||||
stats_render.increment_callback_xrun();
|
|
||||||
}
|
|
||||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
|
||||||
warn!(target: "chanora_audio",
|
|
||||||
"AudioHandler poisoned (raw render): {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// INV_012: copy render reference BEFORE playout.
|
|
||||||
let mono_n = n.min(480);
|
|
||||||
let mut ref_frame = [0.0_f32; 480];
|
|
||||||
crate::voice_render::downmix_stereo_f32_to_mono_f32(
|
|
||||||
&scratch[..stereo_n],
|
|
||||||
&mut ref_frame[..mono_n],
|
|
||||||
);
|
|
||||||
render_ref_buf.write(&ref_frame);
|
|
||||||
|
|
||||||
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
|
||||||
let muted = output_muted.load(Ordering::Relaxed);
|
|
||||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
|
||||||
&scratch[..stereo_n],
|
|
||||||
out,
|
|
||||||
gain,
|
|
||||||
muted,
|
|
||||||
);
|
|
||||||
if mix_stats.clipped_samples > 0 {
|
|
||||||
stats_render.add_clipped_samples(mix_stats.clipped_samples);
|
|
||||||
}
|
|
||||||
stats_render.update_render(crate::frame::dbfs(&scratch[..stereo_n]), n as u32);
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio render cb: {e}")))?;
|
|
||||||
|
|
||||||
unit.initialize()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio init: {e}")))?;
|
|
||||||
unit.start()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio start: {e}")))?;
|
|
||||||
|
|
||||||
info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
sample_rate_hz = SAMPLE_RATE_HZ,
|
|
||||||
"ios RemoteIO (Sonora experimental) started"
|
|
||||||
);
|
|
||||||
Ok(Self { unit })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restart the unit after a route change (stop → uninit → init → start).
|
|
||||||
pub fn restart(&mut self) -> Result<(), AudioError> {
|
|
||||||
self.unit
|
|
||||||
.stop()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio restart stop: {e}")))?;
|
|
||||||
self.unit
|
|
||||||
.uninitialize()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio restart uninit: {e}")))?;
|
|
||||||
self.unit
|
|
||||||
.initialize()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio restart init: {e}")))?;
|
|
||||||
self.unit
|
|
||||||
.start()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio restart start: {e}")))?;
|
|
||||||
info!(target: "chanora_audio", "ios RemoteIO restarted");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pause the unit during an AVAudioSession interruption.
|
|
||||||
pub fn pause(&mut self) -> Result<(), AudioError> {
|
|
||||||
self.unit
|
|
||||||
.stop()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio pause: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resume the unit after an interruption ends.
|
|
||||||
pub fn resume(&mut self) -> Result<(), AudioError> {
|
|
||||||
self.unit
|
|
||||||
.start()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio resume: {e}")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for IosRawUnit {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if let Err(e) = self.unit.stop() {
|
|
||||||
warn!(target: "chanora_audio", error = %e,
|
|
||||||
"ios RemoteIO stop on drop failed");
|
|
||||||
} else {
|
|
||||||
info!(target: "chanora_audio", "ios RemoteIO stopped");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
//! * AVAudioSession category / mode configuration — Swift owns the
|
//! * AVAudioSession category / mode configuration — Swift owns the
|
||||||
//! session (it must be set up before Flutter loads).
|
//! session (it must be set up before Flutter loads).
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use audiopus::coder::Encoder as OpusEncoder;
|
use audiopus::coder::Encoder as OpusEncoder;
|
||||||
@@ -75,12 +75,10 @@ use coreaudio::audio_unit::render_callback::{self, data};
|
|||||||
use coreaudio::audio_unit::IOType;
|
use coreaudio::audio_unit::IOType;
|
||||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
||||||
use crossbeam::queue::ArrayQueue;
|
use crossbeam::queue::ArrayQueue;
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::mobile_voice_backend::VoiceAudioParams;
|
use crate::mobile_voice_backend::VoiceAudioParams;
|
||||||
use crate::AudioError;
|
use crate::AudioError;
|
||||||
use chanora_protocol::OutPacket;
|
|
||||||
|
|
||||||
/// Sample rate every layer above us assumes. Matches the Opus
|
/// Sample rate every layer above us assumes. Matches the Opus
|
||||||
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
|
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
|
||||||
@@ -105,6 +103,15 @@ const INPUT_BUS: Element = Element::Input;
|
|||||||
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
|
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
|
||||||
const PRE_ROLL_FRAMES: usize = 16;
|
const PRE_ROLL_FRAMES: usize = 16;
|
||||||
|
|
||||||
|
/// Enough room for the 160 ms VAD pre-roll plus a few jitter frames, without
|
||||||
|
/// growing inside the input callback.
|
||||||
|
const CAPTURE_ACCUM_CAPACITY_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES * 20;
|
||||||
|
|
||||||
|
/// Fixed iOS render scratch capacity. Larger callback requests are truncated
|
||||||
|
/// to this capacity and the remaining output is silence.
|
||||||
|
#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
|
||||||
|
const IOS_RENDER_SCRATCH_FRAMES: usize = 4096;
|
||||||
|
|
||||||
/// Capture pipeline state owned by the VPIO input callback. The
|
/// Capture pipeline state owned by the VPIO input callback. The
|
||||||
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
|
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
|
||||||
/// downmix or resample needed — VPIO's hardware-side mix-down
|
/// downmix or resample needed — VPIO's hardware-side mix-down
|
||||||
@@ -132,10 +139,9 @@ struct IosCaptureState {
|
|||||||
/// jitter without reallocating.
|
/// jitter without reallocating.
|
||||||
pcm_accum: Vec<i16>,
|
pcm_accum: Vec<i16>,
|
||||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||||
@@ -154,7 +160,6 @@ struct IosCaptureState {
|
|||||||
pre_roll_count: usize,
|
pre_roll_count: usize,
|
||||||
pre_roll_flushed: bool,
|
pre_roll_flushed: bool,
|
||||||
capture_frame_seq: u64,
|
capture_frame_seq: u64,
|
||||||
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IosCaptureState {
|
impl IosCaptureState {
|
||||||
@@ -162,20 +167,20 @@ impl IosCaptureState {
|
|||||||
/// Encoder configuration is the same as cpal-side
|
/// Encoder configuration is the same as cpal-side
|
||||||
/// `try_open_capture` (engine.rs) so audio quality is platform-
|
/// `try_open_capture` (engine.rs) so audio quality is platform-
|
||||||
/// neutral.
|
/// neutral.
|
||||||
fn new(
|
fn new(params: &VoiceAudioParams) -> Result<Self, AudioError> {
|
||||||
params: &VoiceAudioParams,
|
|
||||||
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
|
||||||
) -> Result<Self, AudioError> {
|
|
||||||
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
|
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
encoder,
|
encoder,
|
||||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
pcm_accum: Vec::with_capacity(CAPTURE_ACCUM_CAPACITY_SAMPLES),
|
||||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: params.voice_out_tx.clone(),
|
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||||
|
params.voice_out_tx.clone(),
|
||||||
|
params.frames_sent.clone(),
|
||||||
|
"ios-vpio",
|
||||||
|
)?,
|
||||||
transmit_active: params.transmit_active.clone(),
|
transmit_active: params.transmit_active.clone(),
|
||||||
output_muted: params.output_muted.clone(),
|
output_muted: params.output_muted.clone(),
|
||||||
frames_sent: params.frames_sent.clone(),
|
|
||||||
mic_gain: params.mic_gain,
|
mic_gain: params.mic_gain,
|
||||||
voice_activity_selector: params.voice_activity_selector.clone(),
|
voice_activity_selector: params.voice_activity_selector.clone(),
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||||
@@ -193,7 +198,6 @@ impl IosCaptureState {
|
|||||||
pre_roll_count: 0,
|
pre_roll_count: 0,
|
||||||
pre_roll_flushed: false,
|
pre_roll_flushed: false,
|
||||||
capture_frame_seq: 0,
|
capture_frame_seq: 0,
|
||||||
wav_recorder,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +271,6 @@ impl IosCaptureState {
|
|||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
crate::opus_voice::send_voip_frame(
|
crate::opus_voice::send_voip_frame(
|
||||||
&self.voice_out_tx,
|
&self.voice_out_tx,
|
||||||
&self.frames_sent,
|
|
||||||
&self.opus_out,
|
&self.opus_out,
|
||||||
len,
|
len,
|
||||||
|| {
|
|| {
|
||||||
@@ -298,25 +301,9 @@ impl IosCaptureState {
|
|||||||
}
|
}
|
||||||
let input_dbfs = crate::frame::dbfs(&frame);
|
let input_dbfs = crate::frame::dbfs(&frame);
|
||||||
|
|
||||||
// WAV tap: raw mic (before processing, DIAG_002).
|
|
||||||
if let Ok(guard) = self.wav_recorder.try_lock() {
|
|
||||||
if let Some(rec) = guard.as_ref() {
|
|
||||||
rec.push_raw_mic(&frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read config once per frame (try_lock: non-blocking, falls back to
|
// Read config once per frame (try_lock: non-blocking, falls back to
|
||||||
// last-known values if the lock is contended — safe to miss one frame).
|
// last-known values if the lock is contended — safe to miss one frame).
|
||||||
let (
|
let (run_ns, run_agc, run_hpf, vad_backend, vad_hangover, debug_wav_dump_enabled) = self
|
||||||
run_ns,
|
|
||||||
run_agc,
|
|
||||||
run_hpf,
|
|
||||||
vad_backend,
|
|
||||||
vad_hangover,
|
|
||||||
debug_wav_dump_enabled,
|
|
||||||
route,
|
|
||||||
processing_backend,
|
|
||||||
) = self
|
|
||||||
.audio_processing_config
|
.audio_processing_config
|
||||||
.try_lock()
|
.try_lock()
|
||||||
.map(|cfg| {
|
.map(|cfg| {
|
||||||
@@ -332,8 +319,6 @@ impl IosCaptureState {
|
|||||||
cfg.vad_backend,
|
cfg.vad_backend,
|
||||||
cfg.vad_hangover_ms,
|
cfg.vad_hangover_ms,
|
||||||
cfg.debug_wav_dump_enabled,
|
cfg.debug_wav_dump_enabled,
|
||||||
cfg.route,
|
|
||||||
cfg.processing_backend,
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or((
|
.unwrap_or((
|
||||||
@@ -343,10 +328,13 @@ impl IosCaptureState {
|
|||||||
crate::VadBackend::WebrtcVad,
|
crate::VadBackend::WebrtcVad,
|
||||||
crate::voice_activity::VAD_HANGOVER_MS,
|
crate::voice_activity::VAD_HANGOVER_MS,
|
||||||
false,
|
false,
|
||||||
crate::AudioRoute::Unknown,
|
|
||||||
crate::AudioBackend::PlatformVoiceProcessing,
|
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// VPIO realtime callbacks cannot use WavDebugRecorder today: its push
|
||||||
|
// path allocates per frame. Debug WAV capture is intentionally disabled
|
||||||
|
// here until the recorder can hand off preallocated frames.
|
||||||
|
let _ = debug_wav_dump_enabled;
|
||||||
|
|
||||||
let voice_activity_mode = self
|
let voice_activity_mode = self
|
||||||
.voice_activity_selector
|
.voice_activity_selector
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -359,32 +347,13 @@ impl IosCaptureState {
|
|||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch VAD backend only while VoiceActivity mode is active.
|
|
||||||
if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() {
|
|
||||||
if debug_wav_dump_enabled {
|
|
||||||
if recorder_guard.is_none() {
|
|
||||||
*recorder_guard = Some(crate::debug_wav::WavDebugRecorder::start(
|
|
||||||
route,
|
|
||||||
processing_backend,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} else if let Some(recorder) = recorder_guard.take() {
|
|
||||||
recorder.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if voice_activity_mode && vad_backend != self.current_vad_backend {
|
if voice_activity_mode && vad_backend != self.current_vad_backend {
|
||||||
self.current_vad_backend = vad_backend;
|
self.current_vad_backend = vad_backend;
|
||||||
self.fallback_warned_backend = None;
|
self.fallback_warned_backend = None;
|
||||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
if vad_backend == crate::VadBackend::SileroOnnx {
|
||||||
self.silero_coreml_worker =
|
self.silero_coreml_worker = None;
|
||||||
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
if self.silero_coreml_worker.is_none() {
|
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
|
||||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
|
||||||
} else {
|
|
||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
self.silero_coreml_worker = None;
|
self.silero_coreml_worker = None;
|
||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
@@ -461,7 +430,10 @@ impl IosCaptureState {
|
|||||||
} else {
|
} else {
|
||||||
used_fallback_vad = true;
|
used_fallback_vad = true;
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
|
&mut self.vad_detector,
|
||||||
|
&frame,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
@@ -484,13 +456,6 @@ impl IosCaptureState {
|
|||||||
transmit_active,
|
transmit_active,
|
||||||
);
|
);
|
||||||
|
|
||||||
// WAV tap: processed mic (after Rust DSP, DIAG_002).
|
|
||||||
if let Ok(guard) = self.wav_recorder.try_lock() {
|
|
||||||
if let Some(rec) = guard.as_ref() {
|
|
||||||
rec.push_processed_mic(&frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to i16 for accumulation.
|
// Convert to i16 for accumulation.
|
||||||
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
|
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
|
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
|
||||||
@@ -528,7 +493,13 @@ impl IosCaptureState {
|
|||||||
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
|
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
|
||||||
for i in 0..pre_roll_to_emit {
|
for i in 0..pre_roll_to_emit {
|
||||||
let idx = (oldest + i) % PRE_ROLL_FRAMES;
|
let idx = (oldest + i) % PRE_ROLL_FRAMES;
|
||||||
self.pcm_accum.extend_from_slice(&self.pre_roll_buf[idx]);
|
if crate::capture_accumulator::append_i16_bounded(
|
||||||
|
&mut self.pcm_accum,
|
||||||
|
&self.pre_roll_buf[idx],
|
||||||
|
) {
|
||||||
|
self.audio_processing_stats.increment_callback_xrun();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if !transmit_active {
|
} else if !transmit_active {
|
||||||
// Gate closed — reset the flush flag so pre-roll fires again
|
// Gate closed — reset the flush flag so pre-roll fires again
|
||||||
@@ -540,7 +511,9 @@ impl IosCaptureState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.pcm_accum.extend_from_slice(&pcm_frame);
|
if crate::capture_accumulator::append_i16_bounded(&mut self.pcm_accum, &pcm_frame) {
|
||||||
|
self.audio_processing_stats.increment_callback_xrun();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +549,7 @@ impl IosVoiceUnit {
|
|||||||
let unit_arc2 = Arc::clone(&unit_arc);
|
let unit_arc2 = Arc::clone(&unit_arc);
|
||||||
|
|
||||||
dispatch2::DispatchQueue::main().exec_async(move || {
|
dispatch2::DispatchQueue::main().exec_async(move || {
|
||||||
let mut guard = unit_arc2.lock().unwrap();
|
let mut guard = unit_arc2.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let unit = guard.as_mut().unwrap();
|
let unit = guard.as_mut().unwrap();
|
||||||
let _ = tx.send(op(unit));
|
let _ = tx.send(op(unit));
|
||||||
});
|
});
|
||||||
@@ -586,7 +559,7 @@ impl IosVoiceUnit {
|
|||||||
Err(_) => Err("vpio lifecycle: main thread channel closed unexpectedly".to_string()),
|
Err(_) => Err("vpio lifecycle: main thread channel closed unexpectedly".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.unit = unit_arc.lock().unwrap().take();
|
self.unit = unit_arc.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||||
result.map_err(AudioError::Backend)
|
result.map_err(AudioError::Backend)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,9 +667,7 @@ impl IosVoiceUnit {
|
|||||||
Element::Output,
|
Element::Output,
|
||||||
Some(&ducking_config),
|
Some(&ducking_config),
|
||||||
) {
|
) {
|
||||||
tracing::debug!(
|
tracing::debug!("vpio set OtherAudioDuckingConfiguration failed (older OS?): {e}");
|
||||||
"vpio set OtherAudioDuckingConfiguration failed (older OS?): {e}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: we keep VPIO's voice processing chain ENABLED
|
// Note: we keep VPIO's voice processing chain ENABLED
|
||||||
@@ -748,18 +719,7 @@ impl IosVoiceUnit {
|
|||||||
// scratch are owned by the closure — no Mutex needed
|
// scratch are owned by the closure — no Mutex needed
|
||||||
// because the input callback is the sole writer/reader on
|
// because the input callback is the sole writer/reader on
|
||||||
// the audio thread.
|
// the audio thread.
|
||||||
let wav_recorder = Arc::new(Mutex::new({
|
let mut capture_state = IosCaptureState::new(¶ms)?;
|
||||||
let cfg = params.audio_processing_config.lock().unwrap().clone();
|
|
||||||
if cfg.debug_wav_dump_enabled {
|
|
||||||
Some(crate::debug_wav::WavDebugRecorder::start(
|
|
||||||
cfg.route,
|
|
||||||
cfg.processing_backend,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
let mut capture_state = IosCaptureState::new(¶ms, wav_recorder.clone())?;
|
|
||||||
|
|
||||||
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
// VPIO with our pinned stream format delivers
|
// VPIO with our pinned stream format delivers
|
||||||
@@ -879,11 +839,8 @@ impl IosVoiceUnit {
|
|||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut pull_scratch: Vec<f32> = vec![0.0; PULL_SAMPLES];
|
let mut pull_scratch: Vec<f32> = vec![0.0; PULL_SAMPLES];
|
||||||
let mut interval =
|
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
||||||
tokio::time::interval(std::time::Duration::from_millis(20));
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
interval.set_missed_tick_behavior(
|
|
||||||
tokio::time::MissedTickBehavior::Delay,
|
|
||||||
);
|
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if producer_shutdown_for_task.load(Ordering::Relaxed) {
|
if producer_shutdown_for_task.load(Ordering::Relaxed) {
|
||||||
@@ -909,16 +866,16 @@ impl IosVoiceUnit {
|
|||||||
|
|
||||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
let render_callback::Args {
|
let render_callback::Args {
|
||||||
data,
|
data, num_frames, ..
|
||||||
num_frames,
|
|
||||||
..
|
|
||||||
} = args;
|
} = args;
|
||||||
let out: &mut [i16] = data.buffer;
|
let out: &mut [i16] = data.buffer;
|
||||||
let out_channels = data.channels;
|
let out_channels = data.channels;
|
||||||
let needed = num_frames * out_channels;
|
let needed = num_frames * out_channels;
|
||||||
|
|
||||||
if pcm_ring_consumer.len() < PREBUFFER_SAMPLES {
|
if pcm_ring_consumer.len() < PREBUFFER_SAMPLES {
|
||||||
for sample in &mut out[..needed] { *sample = 0; }
|
for sample in &mut out[..needed] {
|
||||||
|
*sample = 0;
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -939,10 +896,8 @@ impl IosVoiceUnit {
|
|||||||
let mono = (l_lim + r_lim) * 0.5;
|
let mono = (l_lim + r_lim) * 0.5;
|
||||||
out[base] = (mono.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
out[base] = (mono.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||||
} else {
|
} else {
|
||||||
out[base] =
|
out[base] = (l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||||
(l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
out[base + 1] = (r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||||
out[base + 1] =
|
|
||||||
(r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
|
||||||
}
|
}
|
||||||
written_frames += 1;
|
written_frames += 1;
|
||||||
}
|
}
|
||||||
@@ -951,17 +906,18 @@ impl IosVoiceUnit {
|
|||||||
let remaining = num_frames - written_frames;
|
let remaining = num_frames - written_frames;
|
||||||
for f in 0..remaining {
|
for f in 0..remaining {
|
||||||
let base = (written_frames + f) * out_channels;
|
let base = (written_frames + f) * out_channels;
|
||||||
for c in 0..out_channels { out[base + c] = 0; }
|
for c in 0..out_channels {
|
||||||
|
out[base + c] = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let gain = f32::from_bits(
|
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||||
output_gain_for_render.load(Ordering::Relaxed),
|
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||||
);
|
|
||||||
let muted =
|
|
||||||
output_muted_for_render.load(Ordering::Relaxed);
|
|
||||||
if muted {
|
if muted {
|
||||||
for sample in &mut out[..needed] { *sample = 0; }
|
for sample in &mut out[..needed] {
|
||||||
|
*sample = 0;
|
||||||
|
}
|
||||||
} else if gain != 1.0 {
|
} else if gain != 1.0 {
|
||||||
for sample in &mut out[..needed] {
|
for sample in &mut out[..needed] {
|
||||||
*sample = (((*sample as f32) * gain)
|
*sample = (((*sample as f32) * gain)
|
||||||
@@ -972,9 +928,7 @@ impl IosVoiceUnit {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.map_err(|e| AudioError::Backend(format!(
|
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||||
"audio unit set render callback: {e}"
|
|
||||||
)))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS path: direct fill_buffer in callback. iOS VPIO
|
// iOS path: direct fill_buffer in callback. iOS VPIO
|
||||||
@@ -984,127 +938,112 @@ impl IosVoiceUnit {
|
|||||||
// producer-task path above.
|
// producer-task path above.
|
||||||
#[cfg(target_os = "ios")]
|
#[cfg(target_os = "ios")]
|
||||||
{
|
{
|
||||||
let mut scratch_stereo: Vec<f32> = vec![0.0; 4096 * 2];
|
let mut scratch_stereo: Vec<f32> = vec![0.0; IOS_RENDER_SCRATCH_FRAMES * 2];
|
||||||
let handler_for_render = params.handler.clone();
|
let handler_for_render = params.handler.clone();
|
||||||
let output_gain_for_render = params.output_gain.clone();
|
let output_gain_for_render = params.output_gain.clone();
|
||||||
let output_muted_for_render = params.output_muted.clone();
|
let output_muted_for_render = params.output_muted.clone();
|
||||||
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
||||||
// Level meter decimation: the render callback fires ~93
|
// Level meter decimation: the render callback fires ~93
|
||||||
// times/sec, but the bridge consumer reads at ~30 Hz.
|
// times/sec, but the bridge consumer reads at ~30 Hz.
|
||||||
let mut render_level_decimation: u32 = 0;
|
let mut render_level_decimation: u32 = 0;
|
||||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
// Debug WAV render-reference capture is intentionally unavailable
|
||||||
let render_callback::Args {
|
// on iOS VPIO callbacks until WavDebugRecorder supports a
|
||||||
data,
|
// preallocated handoff; its current push path allocates per frame.
|
||||||
num_frames,
|
// Diagnostic counters sampled every 100 callbacks.
|
||||||
..
|
let mut cb_count: u64 = 0;
|
||||||
} = args;
|
let mut last_num_frames: usize = 0;
|
||||||
let out: &mut [i16] = data.buffer;
|
let mut num_frames_changes: u64 = 0;
|
||||||
let out_channels = data.channels;
|
let mut callbacks_with_audio: u64 = 0;
|
||||||
// AudioHandler produces 48 kHz stereo f32 (= num_frames * 2 floats).
|
let mut callbacks_with_silence: u64 = 0;
|
||||||
let needed = num_frames * 2;
|
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
if scratch_stereo.len() < needed {
|
let render_callback::Args {
|
||||||
scratch_stereo.resize(needed, 0.0);
|
data, num_frames, ..
|
||||||
}
|
} = args;
|
||||||
// Zero the live slice. AudioHandler::fill_buffer is
|
let out: &mut [i16] = data.buffer;
|
||||||
// additive (does NOT clear); residual values from
|
let out_channels = data.channels;
|
||||||
// earlier callbacks (when scratch was bigger) would
|
let process_frames = num_frames.min(IOS_RENDER_SCRATCH_FRAMES);
|
||||||
// leak through otherwise.
|
if process_frames < num_frames {
|
||||||
scratch_stereo[..needed].fill(0.0);
|
|
||||||
match handler_for_render.try_lock() {
|
|
||||||
Ok(mut h) => {
|
|
||||||
let _ = h.fill_buffer(&mut scratch_stereo[..needed]);
|
|
||||||
}
|
|
||||||
Err(std::sync::TryLockError::WouldBlock) => {
|
|
||||||
audio_processing_stats_for_render.increment_callback_xrun();
|
audio_processing_stats_for_render.increment_callback_xrun();
|
||||||
// scratch_stereo is already zeroed above.
|
|
||||||
}
|
}
|
||||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
// AudioHandler produces 48 kHz stereo f32 (= frames * 2 floats).
|
||||||
// Never panic on the realtime IO thread.
|
let needed = process_frames * 2;
|
||||||
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
|
// Zero the live slice. AudioHandler::fill_buffer is
|
||||||
}
|
// additive (does NOT clear); residual values from
|
||||||
}
|
// earlier callbacks (when scratch was bigger) would
|
||||||
// Peak limiter — multi-client mixes can sum past 0 dBFS;
|
// leak through otherwise.
|
||||||
// without this the downmix helper would hard-clip to i16::MAX.
|
scratch_stereo[..needed].fill(0.0);
|
||||||
crate::voice_render::limit_peak_inplace(&mut scratch_stereo[..needed], 0.99);
|
match handler_for_render.try_lock() {
|
||||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
Ok(mut h) => {
|
||||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
let _ = h.fill_buffer(&mut scratch_stereo[..needed]);
|
||||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
|
||||||
&scratch_stereo[..needed],
|
|
||||||
out,
|
|
||||||
out_channels,
|
|
||||||
gain,
|
|
||||||
muted,
|
|
||||||
);
|
|
||||||
if mix_stats.clipped_samples > 0 {
|
|
||||||
audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples);
|
|
||||||
}
|
|
||||||
render_level_decimation = render_level_decimation.wrapping_add(1);
|
|
||||||
if render_level_decimation % 3 == 0 {
|
|
||||||
audio_processing_stats_for_render.update_render(
|
|
||||||
crate::frame::dbfs(&scratch_stereo[..needed]),
|
|
||||||
num_frames as u32,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(guard) = wav_recorder_for_render.try_lock() {
|
|
||||||
if let Some(rec) = guard.as_ref() {
|
|
||||||
if !render_recorder_active {
|
|
||||||
render_ref_len = 0;
|
|
||||||
render_ref_accum.fill(0.0);
|
|
||||||
render_recorder_active = true;
|
|
||||||
}
|
}
|
||||||
let mut idx = 0;
|
Err(std::sync::TryLockError::WouldBlock) => {
|
||||||
while idx + 1 < needed {
|
audio_processing_stats_for_render.increment_callback_xrun();
|
||||||
let mono = (scratch_stereo[idx] + scratch_stereo[idx + 1]) * 0.5;
|
// scratch_stereo is already zeroed above.
|
||||||
render_ref_accum[render_ref_len] = mono;
|
}
|
||||||
render_ref_len += 1;
|
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||||
idx += 2;
|
// Never panic on the realtime IO thread.
|
||||||
if render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
|
||||||
rec.push_render_reference(&render_ref_accum);
|
|
||||||
render_ref_len = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
render_recorder_active = false;
|
|
||||||
}
|
}
|
||||||
} else {
|
// Peak limiter — multi-client mixes can sum past 0 dBFS;
|
||||||
render_recorder_active = false;
|
// without this the downmix helper would hard-clip to i16::MAX.
|
||||||
}
|
crate::voice_render::limit_peak_inplace(&mut scratch_stereo[..needed], 0.99);
|
||||||
// Track audio-vs-silence for the diagnostic.
|
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||||
if mix_stats.peak_i16 > 0 {
|
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||||
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
||||||
} else {
|
&scratch_stereo[..needed],
|
||||||
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
out,
|
||||||
// Muted output writes intentional silence (peak_i16 == 0 by
|
out_channels,
|
||||||
// design), not a starved render path. Gate on !muted to avoid
|
|
||||||
// counting deliberate silence as an output underrun.
|
|
||||||
if !muted {
|
|
||||||
audio_processing_stats_for_render.increment_output_underrun();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diagnostic sampling.
|
|
||||||
if last_num_frames != 0 && last_num_frames != num_frames {
|
|
||||||
num_frames_changes = num_frames_changes.wrapping_add(1);
|
|
||||||
}
|
|
||||||
last_num_frames = num_frames;
|
|
||||||
cb_count = cb_count.wrapping_add(1);
|
|
||||||
if cb_count.is_multiple_of(100) {
|
|
||||||
debug!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
cb = cb_count,
|
|
||||||
num_frames,
|
|
||||||
frames_changes = num_frames_changes,
|
|
||||||
callbacks_with_audio,
|
|
||||||
callbacks_with_silence,
|
|
||||||
peak_out_i16 = mix_stats.peak_i16,
|
|
||||||
gain,
|
gain,
|
||||||
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
muted,
|
||||||
);
|
);
|
||||||
}
|
if mix_stats.clipped_samples > 0 {
|
||||||
Ok(())
|
audio_processing_stats_for_render
|
||||||
})
|
.add_clipped_samples(mix_stats.clipped_samples);
|
||||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
}
|
||||||
|
render_level_decimation = render_level_decimation.wrapping_add(1);
|
||||||
|
if render_level_decimation % 3 == 0 {
|
||||||
|
audio_processing_stats_for_render.update_render(
|
||||||
|
crate::frame::dbfs(&scratch_stereo[..needed]),
|
||||||
|
num_frames as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track audio-vs-silence for the diagnostic.
|
||||||
|
if mix_stats.peak_i16 > 0 {
|
||||||
|
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
||||||
|
} else {
|
||||||
|
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
||||||
|
// Muted output writes intentional silence (peak_i16 == 0 by
|
||||||
|
// design), not a starved render path. Gate on !muted to avoid
|
||||||
|
// counting deliberate silence as an output underrun.
|
||||||
|
if !muted {
|
||||||
|
audio_processing_stats_for_render.increment_output_underrun();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostic sampling.
|
||||||
|
if last_num_frames != 0 && last_num_frames != num_frames {
|
||||||
|
num_frames_changes = num_frames_changes.wrapping_add(1);
|
||||||
|
}
|
||||||
|
last_num_frames = num_frames;
|
||||||
|
cb_count = cb_count.wrapping_add(1);
|
||||||
|
if cb_count.is_multiple_of(100) {
|
||||||
|
debug!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
cb = cb_count,
|
||||||
|
num_frames,
|
||||||
|
frames_changes = num_frames_changes,
|
||||||
|
callbacks_with_audio,
|
||||||
|
callbacks_with_silence,
|
||||||
|
peak_out_i16 = mix_stats.peak_i16,
|
||||||
|
gain,
|
||||||
|
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||||
} // end #[cfg(target_os = "ios")] block
|
} // end #[cfg(target_os = "ios")] block
|
||||||
|
|
||||||
// Finalise the unit — allocates internal buffers per the
|
// Finalise the unit — allocates internal buffers per the
|
||||||
@@ -1133,7 +1072,7 @@ impl IosVoiceUnit {
|
|||||||
let unit_arc2 = unit_arc.clone();
|
let unit_arc2 = unit_arc.clone();
|
||||||
|
|
||||||
dispatch2::DispatchQueue::main().exec_async(move || {
|
dispatch2::DispatchQueue::main().exec_async(move || {
|
||||||
let mut guard = unit_arc2.lock().unwrap();
|
let mut guard = unit_arc2.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let u = guard.as_mut().unwrap();
|
let u = guard.as_mut().unwrap();
|
||||||
let result = u
|
let result = u
|
||||||
.initialize()
|
.initialize()
|
||||||
@@ -1155,7 +1094,7 @@ impl IosVoiceUnit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unit = unit_arc.lock().unwrap().take().unwrap();
|
unit = unit_arc.lock().unwrap_or_else(|e| e.into_inner()).take().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
|
|||||||
@@ -28,10 +28,17 @@
|
|||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "android", test))]
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
mod android_render_ring;
|
||||||
#[cfg(any(target_os = "android", test))]
|
#[cfg(any(target_os = "android", test))]
|
||||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
mod audio_event_queue;
|
mod audio_event_queue;
|
||||||
pub mod audio_processing;
|
pub mod audio_processing;
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
mod capture_accumulator;
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
mod capture_resampler;
|
||||||
pub mod debug_wav;
|
pub mod debug_wav;
|
||||||
mod engine;
|
mod engine;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
@@ -42,6 +49,11 @@ pub mod processor;
|
|||||||
pub mod ptt;
|
pub mod ptt;
|
||||||
pub mod ptt_backends;
|
pub mod ptt_backends;
|
||||||
pub mod release_tail;
|
pub mod release_tail;
|
||||||
|
#[cfg_attr(
|
||||||
|
not(any(target_os = "android", target_os = "ios", test)),
|
||||||
|
allow(dead_code)
|
||||||
|
)]
|
||||||
|
pub(crate) mod render_reference;
|
||||||
pub mod route_policy;
|
pub mod route_policy;
|
||||||
pub mod transmit_mode;
|
pub mod transmit_mode;
|
||||||
pub mod transmit_selector;
|
pub mod transmit_selector;
|
||||||
@@ -53,10 +65,7 @@ pub(crate) mod voice_render;
|
|||||||
mod sdl_output;
|
mod sdl_output;
|
||||||
|
|
||||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
mod ios_voice_unit;
|
mod ios_voice_unit;
|
||||||
|
|
||||||
#[cfg(target_os = "ios")]
|
|
||||||
pub mod ios_raw_unit;
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub mod android_voice_unit;
|
pub mod android_voice_unit;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
//! `cpal` (and SDL on Linux) own desktop capture/playback per the
|
//! `cpal` (and SDL on Linux) own desktop capture/playback per the
|
||||||
//! existing audio engine design.
|
//! existing audio engine design.
|
||||||
|
|
||||||
// TODO(SDD-117): back-fill `IosVoiceUnit` to implement this trait
|
// TRACKED(SDD-117): back-fill `IosVoiceUnit` to implement this trait
|
||||||
// so the engine can hold a single `Box<dyn MobileVoiceAudioBackend>`
|
// so the engine can hold a single `Box<dyn MobileVoiceAudioBackend>`
|
||||||
// across iOS and Android.
|
// across iOS and Android.
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ use audiopus::{
|
|||||||
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
||||||
SampleRate as OpusSampleRate,
|
SampleRate as OpusSampleRate,
|
||||||
};
|
};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use crossbeam::queue::ArrayQueue;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
||||||
|
|
||||||
use crate::AudioError;
|
use crate::AudioError;
|
||||||
|
|
||||||
pub(crate) const MAX_OPUS_FRAME: usize = 1275;
|
pub(crate) const MAX_OPUS_FRAME: usize = 1275;
|
||||||
|
const VOICE_FRAME_QUEUE_CAPACITY: usize = 64;
|
||||||
|
|
||||||
const VOIP_BITRATE_BPS: i32 = 32_000;
|
const VOIP_BITRATE_BPS: i32 = 32_000;
|
||||||
const VOIP_COMPLEXITY: u8 = 10;
|
const VOIP_COMPLEXITY: u8 = 10;
|
||||||
@@ -54,10 +57,129 @@ pub(crate) fn tune_voip_encoder(encoder: &mut OpusEncoder, context: &str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct EncodedVoiceFrame {
|
||||||
|
data: [u8; MAX_OPUS_FRAME],
|
||||||
|
len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct EncodedVoiceFrameSender {
|
||||||
|
queue: Arc<ArrayQueue<EncodedVoiceFrame>>,
|
||||||
|
open: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EncodedVoiceFrameSendError {
|
||||||
|
Full,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncodedVoiceFrameSender {
|
||||||
|
fn new(capacity: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
queue: Arc::new(ArrayQueue::new(capacity)),
|
||||||
|
open: Arc::new(AtomicBool::new(true)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_queue(&self) -> Arc<ArrayQueue<EncodedVoiceFrame>> {
|
||||||
|
Arc::clone(&self.queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_open_flag(&self) -> Arc<AtomicBool> {
|
||||||
|
Arc::clone(&self.open)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&self, frame: EncodedVoiceFrame) -> Result<(), EncodedVoiceFrameSendError> {
|
||||||
|
if !self.open.load(Ordering::Relaxed) {
|
||||||
|
return Err(EncodedVoiceFrameSendError::Closed);
|
||||||
|
}
|
||||||
|
self.queue
|
||||||
|
.push(frame)
|
||||||
|
.map_err(|_| EncodedVoiceFrameSendError::Full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn start_out_packet_worker(
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
context: &'static str,
|
||||||
|
) -> Result<EncodedVoiceFrameSender, AudioError> {
|
||||||
|
start_out_packet_worker_with_spawner(voice_out_tx, frames_sent, context, |name, worker| {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name(name)
|
||||||
|
.spawn(worker)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_out_packet_worker_with_spawner<S>(
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
context: &'static str,
|
||||||
|
spawn: S,
|
||||||
|
) -> Result<EncodedVoiceFrameSender, AudioError>
|
||||||
|
where
|
||||||
|
S: FnOnce(String, Box<dyn FnOnce() + Send + 'static>) -> std::io::Result<()>,
|
||||||
|
{
|
||||||
|
let tx = EncodedVoiceFrameSender::new(VOICE_FRAME_QUEUE_CAPACITY);
|
||||||
|
let rx = tx.worker_queue();
|
||||||
|
let worker_open = tx.worker_open_flag();
|
||||||
|
spawn(
|
||||||
|
format!("chanora-{context}-voice-packets"),
|
||||||
|
Box::new(move || {
|
||||||
|
loop {
|
||||||
|
let Some(frame) = rx.pop() else {
|
||||||
|
if Arc::strong_count(&rx) == 1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let packet = OutAudio::new(&AudioData::C2S {
|
||||||
|
id: 0,
|
||||||
|
codec: CodecType::OpusVoice,
|
||||||
|
data: frame.as_slice(),
|
||||||
|
});
|
||||||
|
match voice_out_tx.try_send(packet) {
|
||||||
|
Ok(()) => {
|
||||||
|
frames_sent.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||||
|
warn!(target: "chanora_audio", context = %context, "voice_out queue full; dropping frame");
|
||||||
|
}
|
||||||
|
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||||
|
debug!(target: "chanora_audio", context = %context, "voice_out closed; voice packet worker stopping");
|
||||||
|
worker_open.store(false, Ordering::Relaxed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
tx.open.store(false, Ordering::Relaxed);
|
||||||
|
AudioError::Backend(format!("voice packet worker spawn ({context}): {e}"))
|
||||||
|
})?;
|
||||||
|
Ok(tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncodedVoiceFrame {
|
||||||
|
fn try_from_opus(opus_out: &[u8], len: usize) -> Option<Self> {
|
||||||
|
if len > opus_out.len() || len > MAX_OPUS_FRAME {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut data = [0u8; MAX_OPUS_FRAME];
|
||||||
|
data[..len].copy_from_slice(&opus_out[..len]);
|
||||||
|
Some(Self { data, len })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_slice(&self) -> &[u8] {
|
||||||
|
&self.data[..self.len]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Encode-scope send helper for a freshly encoded Opus voice frame.
|
/// Encode-scope send helper for a freshly encoded Opus voice frame.
|
||||||
pub(crate) fn send_voip_frame<F, G>(
|
pub(crate) fn send_voip_frame<F, G>(
|
||||||
voice_out_tx: &mpsc::Sender<OutPacket>,
|
voice_out_tx: &EncodedVoiceFrameSender,
|
||||||
frames_sent: &AtomicU32,
|
|
||||||
opus_out: &[u8],
|
opus_out: &[u8],
|
||||||
len: usize,
|
len: usize,
|
||||||
on_full: F,
|
on_full: F,
|
||||||
@@ -66,16 +188,77 @@ pub(crate) fn send_voip_frame<F, G>(
|
|||||||
F: FnOnce(),
|
F: FnOnce(),
|
||||||
G: FnOnce(),
|
G: FnOnce(),
|
||||||
{
|
{
|
||||||
let packet = OutAudio::new(&AudioData::C2S {
|
let Some(frame) = EncodedVoiceFrame::try_from_opus(opus_out, len) else {
|
||||||
id: 0,
|
on_full();
|
||||||
codec: CodecType::OpusVoice,
|
return;
|
||||||
data: &opus_out[..len],
|
};
|
||||||
});
|
match voice_out_tx.push(frame) {
|
||||||
match voice_out_tx.try_send(packet) {
|
Ok(()) => {}
|
||||||
Ok(()) => {
|
Err(EncodedVoiceFrameSendError::Full) => on_full(),
|
||||||
frames_sent.fetch_add(1, Ordering::Relaxed);
|
Err(EncodedVoiceFrameSendError::Closed) => on_closed(),
|
||||||
}
|
}
|
||||||
Err(mpsc::error::TrySendError::Full(_)) => on_full(),
|
}
|
||||||
Err(mpsc::error::TrySendError::Closed(_)) => on_closed(),
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoded_voice_frame_copies_into_fixed_storage() {
|
||||||
|
let source = [7u8; MAX_OPUS_FRAME];
|
||||||
|
|
||||||
|
let frame = EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(frame.as_slice().len(), MAX_OPUS_FRAME);
|
||||||
|
assert!(frame.as_slice().iter().all(|byte| *byte == 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoded_voice_frame_rejects_lengths_beyond_fixed_storage() {
|
||||||
|
let source = [0u8; MAX_OPUS_FRAME];
|
||||||
|
|
||||||
|
assert!(EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME + 1).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoded_voice_frame_sender_reports_full_without_blocking() {
|
||||||
|
let sender = EncodedVoiceFrameSender::new(1);
|
||||||
|
let source = [3u8; MAX_OPUS_FRAME];
|
||||||
|
let first = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
||||||
|
let second = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
||||||
|
|
||||||
|
assert!(sender.push(first).is_ok());
|
||||||
|
assert!(sender.push(second).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoded_voice_frame_sender_reports_closed_without_queueing() {
|
||||||
|
let sender = EncodedVoiceFrameSender::new(1);
|
||||||
|
sender.open.store(false, Ordering::Relaxed);
|
||||||
|
let source = [3u8; MAX_OPUS_FRAME];
|
||||||
|
let frame = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
sender.push(frame),
|
||||||
|
Err(EncodedVoiceFrameSendError::Closed)
|
||||||
|
));
|
||||||
|
assert_eq!(sender.queue.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoded_voice_frame_sender_reports_spawn_failure() {
|
||||||
|
let (voice_out_tx, _voice_out_rx) = mpsc::channel(1);
|
||||||
|
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||||
|
|
||||||
|
let result = start_out_packet_worker_with_spawner(
|
||||||
|
voice_out_tx,
|
||||||
|
frames_sent,
|
||||||
|
"test",
|
||||||
|
|_name, _worker| Err(std::io::Error::other("spawn failed")),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(AudioError::Backend(message)) if message.contains("spawn failed"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -543,7 +543,7 @@ impl DesktopPttBackend for MacOSEventTapBackend {
|
|||||||
}
|
}
|
||||||
let runloop = unsafe { CFRunLoopGetCurrent() };
|
let runloop = unsafe { CFRunLoopGetCurrent() };
|
||||||
{
|
{
|
||||||
let mut g = worker_runloop.lock().unwrap();
|
let mut g = worker_runloop.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
*g = Some(RunLoopHandle(runloop));
|
*g = Some(RunLoopHandle(runloop));
|
||||||
}
|
}
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
use core::fmt;
|
use core::fmt;
|
||||||
|
|
||||||
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
|
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
mod focused;
|
mod focused;
|
||||||
|
|
||||||
@@ -108,35 +109,51 @@ impl fmt::Display for PttInputClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Errors raised by a desktop PTT backend.
|
/// Errors raised by a desktop PTT backend.
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Error)]
|
||||||
pub enum PttBackendError {
|
pub enum PttBackendError {
|
||||||
/// The OS rejected the backend initialisation (e.g. Raw Input
|
/// The OS rejected the backend initialisation (e.g. Raw Input
|
||||||
/// registration failed, event tap creation failed).
|
/// registration failed, event tap creation failed).
|
||||||
|
#[error("init failed: {0}")]
|
||||||
Init(String),
|
Init(String),
|
||||||
/// The user-granted permission required for global capture is
|
/// The user-granted permission required for global capture is
|
||||||
/// not granted (typically macOS Input Monitoring / Accessibility).
|
/// not granted (typically macOS Input Monitoring / Accessibility).
|
||||||
|
#[error("permission denied")]
|
||||||
PermissionDenied,
|
PermissionDenied,
|
||||||
/// The display server or compositor does not expose the
|
/// The display server or compositor does not expose the
|
||||||
/// expected interface (typically a non-tested Linux compositor).
|
/// expected interface (typically a non-tested Linux compositor).
|
||||||
|
#[error("unsupported environment")]
|
||||||
UnsupportedEnvironment,
|
UnsupportedEnvironment,
|
||||||
/// Caller submitted a binding whose `platform_key` cannot be
|
/// Caller submitted a binding whose `platform_key` cannot be
|
||||||
/// parsed in the active OS.
|
/// parsed in the active OS.
|
||||||
|
#[error("invalid binding: {0}")]
|
||||||
InvalidBinding(String),
|
InvalidBinding(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for PttBackendError {
|
#[cfg(test)]
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
mod tests {
|
||||||
match self {
|
use super::*;
|
||||||
Self::Init(s) => write!(f, "init failed: {s}"),
|
|
||||||
Self::PermissionDenied => f.write_str("permission denied"),
|
#[test]
|
||||||
Self::UnsupportedEnvironment => f.write_str("unsupported environment"),
|
fn ptt_backend_error_display_strings_stay_stable() {
|
||||||
Self::InvalidBinding(s) => write!(f, "invalid binding: {s}"),
|
assert_eq!(
|
||||||
}
|
PttBackendError::Init("rawinput".into()).to_string(),
|
||||||
|
"init failed: rawinput"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PttBackendError::PermissionDenied.to_string(),
|
||||||
|
"permission denied"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PttBackendError::UnsupportedEnvironment.to_string(),
|
||||||
|
"unsupported environment"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PttBackendError::InvalidBinding("bad key".into()).to_string(),
|
||||||
|
"invalid binding: bad key"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for PttBackendError {}
|
|
||||||
|
|
||||||
/// Cross-platform desktop PTT backend (SDD-081).
|
/// Cross-platform desktop PTT backend (SDD-081).
|
||||||
///
|
///
|
||||||
/// All implementations call exactly the audio transmit gate's
|
/// All implementations call exactly the audio transmit gate's
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use std::thread;
|
|||||||
|
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
use windows::core::{w, PCWSTR};
|
use windows::core::{w, PCWSTR};
|
||||||
use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM};
|
use windows::Win32::Foundation::{HINSTANCE, HMODULE, HWND, LPARAM, LRESULT, WPARAM};
|
||||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
use windows::Win32::UI::Input::{
|
use windows::Win32::UI::Input::{
|
||||||
GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE, RAWINPUTHEADER,
|
GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE, RAWINPUTHEADER,
|
||||||
@@ -35,7 +35,7 @@ use windows::Win32::UI::Input::{
|
|||||||
use windows::Win32::UI::WindowsAndMessaging::{
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
CallNextHookEx, CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW,
|
CallNextHookEx, CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW,
|
||||||
PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx,
|
PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx,
|
||||||
HC_ACTION, HHOOK, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
|
HC_ACTION, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
|
||||||
WINDOW_EX_STYLE, WINDOW_STYLE, WM_INPUT, WM_KEYDOWN, WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN,
|
WINDOW_EX_STYLE, WINDOW_STYLE, WM_INPUT, WM_KEYDOWN, WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN,
|
||||||
WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, XBUTTON1, XBUTTON2,
|
WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, XBUTTON1, XBUTTON2,
|
||||||
};
|
};
|
||||||
@@ -423,7 +423,7 @@ unsafe fn run_raw_input_loop(
|
|||||||
// class.
|
// class.
|
||||||
let _atom = RegisterClassExW(&wc);
|
let _atom = RegisterClassExW(&wc);
|
||||||
|
|
||||||
let hwnd = unsafe {
|
let hwnd = match unsafe {
|
||||||
CreateWindowExW(
|
CreateWindowExW(
|
||||||
WINDOW_EX_STYLE(0),
|
WINDOW_EX_STYLE(0),
|
||||||
class_name,
|
class_name,
|
||||||
@@ -433,13 +433,23 @@ unsafe fn run_raw_input_loop(
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
HWND(HWND_MESSAGE_PTR),
|
Some(HWND(HWND_MESSAGE_PTR as *mut core::ffi::c_void)),
|
||||||
None,
|
None,
|
||||||
h_instance,
|
Some(HINSTANCE(h_instance.0)),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
} {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
"windows ptt: CreateWindowExW(HWND_MESSAGE) returned null"
|
||||||
|
);
|
||||||
|
report!(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if hwnd.0 == 0 {
|
if hwnd.0.is_null() {
|
||||||
warn!(
|
warn!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
"windows ptt: CreateWindowExW(HWND_MESSAGE) returned null"
|
"windows ptt: CreateWindowExW(HWND_MESSAGE) returned null"
|
||||||
@@ -517,13 +527,13 @@ unsafe fn run_raw_input_loop(
|
|||||||
usUsagePage: 0x01,
|
usUsagePage: 0x01,
|
||||||
usUsage: 0x06,
|
usUsage: 0x06,
|
||||||
dwFlags: RIDEV_REMOVE,
|
dwFlags: RIDEV_REMOVE,
|
||||||
hwndTarget: HWND(0),
|
hwndTarget: HWND(std::ptr::null_mut()),
|
||||||
},
|
},
|
||||||
RAWINPUTDEVICE {
|
RAWINPUTDEVICE {
|
||||||
usUsagePage: 0x01,
|
usUsagePage: 0x01,
|
||||||
usUsage: 0x02,
|
usUsage: 0x02,
|
||||||
dwFlags: RIDEV_REMOVE,
|
dwFlags: RIDEV_REMOVE,
|
||||||
hwndTarget: HWND(0),
|
hwndTarget: HWND(std::ptr::null_mut()),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let _ = RegisterRawInputDevices(&undo, std::mem::size_of::<RAWINPUTDEVICE>() as u32);
|
let _ = RegisterRawInputDevices(&undo, std::mem::size_of::<RAWINPUTDEVICE>() as u32);
|
||||||
@@ -544,7 +554,7 @@ unsafe extern "system" fn raw_input_wnd_proc(
|
|||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn handle_wm_input(lparam: LPARAM) {
|
unsafe fn handle_wm_input(lparam: LPARAM) {
|
||||||
let h_raw = HRAWINPUT(lparam.0);
|
let h_raw = HRAWINPUT(lparam.0 as *mut core::ffi::c_void);
|
||||||
let mut size: u32 = 0;
|
let mut size: u32 = 0;
|
||||||
let header_sz = std::mem::size_of::<RAWINPUTHEADER>() as u32;
|
let header_sz = std::mem::size_of::<RAWINPUTHEADER>() as u32;
|
||||||
// First call: query buffer size.
|
// First call: query buffer size.
|
||||||
@@ -841,31 +851,33 @@ unsafe fn run_hook_loop(
|
|||||||
let kbd_proc: HOOKPROC = Some(kbd_hook_proc);
|
let kbd_proc: HOOKPROC = Some(kbd_hook_proc);
|
||||||
let mouse_proc: HOOKPROC = Some(mouse_hook_proc);
|
let mouse_proc: HOOKPROC = Some(mouse_hook_proc);
|
||||||
|
|
||||||
let kbd_hook = match SetWindowsHookExW(WH_KEYBOARD_LL, kbd_proc, h_instance, 0) {
|
let kbd_hook =
|
||||||
Ok(h) => h,
|
match SetWindowsHookExW(WH_KEYBOARD_LL, kbd_proc, Some(HINSTANCE(h_instance.0)), 0) {
|
||||||
Err(e) => {
|
Ok(h) => h,
|
||||||
warn!(
|
Err(e) => {
|
||||||
target: "chanora_audio",
|
warn!(
|
||||||
error = %e,
|
target: "chanora_audio",
|
||||||
"windows ptt: SetWindowsHookExW(WH_KEYBOARD_LL) failed"
|
error = %e,
|
||||||
);
|
"windows ptt: SetWindowsHookExW(WH_KEYBOARD_LL) failed"
|
||||||
report!(false);
|
);
|
||||||
return false;
|
report!(false);
|
||||||
}
|
return false;
|
||||||
};
|
}
|
||||||
let mouse_hook = match SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, h_instance, 0) {
|
};
|
||||||
Ok(h) => h,
|
let mouse_hook =
|
||||||
Err(e) => {
|
match SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, Some(HINSTANCE(h_instance.0)), 0) {
|
||||||
warn!(
|
Ok(h) => h,
|
||||||
target: "chanora_audio",
|
Err(e) => {
|
||||||
error = %e,
|
warn!(
|
||||||
"windows ptt: SetWindowsHookExW(WH_MOUSE_LL) failed"
|
target: "chanora_audio",
|
||||||
);
|
error = %e,
|
||||||
let _ = UnhookWindowsHookEx(kbd_hook);
|
"windows ptt: SetWindowsHookExW(WH_MOUSE_LL) failed"
|
||||||
report!(false);
|
);
|
||||||
return false;
|
let _ = UnhookWindowsHookEx(kbd_hook);
|
||||||
}
|
report!(false);
|
||||||
};
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
@@ -908,7 +920,7 @@ unsafe extern "system" fn kbd_hook_proc(code: i32, wparam: WPARAM, lparam: LPARA
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
CallNextHookEx(HHOOK(0), code, wparam, lparam)
|
CallNextHookEx(None, code, wparam, lparam)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure-logic dispatcher for a low-level keyboard hook event (L0
|
/// Pure-logic dispatcher for a low-level keyboard hook event (L0
|
||||||
@@ -943,7 +955,7 @@ unsafe extern "system" fn mouse_hook_proc(code: i32, wparam: WPARAM, lparam: LPA
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
CallNextHookEx(HHOOK(0), code, wparam, lparam)
|
CallNextHookEx(None, code, wparam, lparam)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure-logic dispatcher for a low-level mouse hook event (L0
|
/// Pure-logic dispatcher for a low-level mouse hook event (L0
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
use std::array;
|
||||||
|
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const NO_LATEST_SLOT: usize = usize::MAX;
|
||||||
|
|
||||||
|
struct Slot<const SAMPLES: usize> {
|
||||||
|
version: AtomicUsize,
|
||||||
|
samples: [AtomicU32; SAMPLES],
|
||||||
|
#[cfg(test)]
|
||||||
|
bump_after_first_sample_read: std::sync::atomic::AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const SAMPLES: usize> Slot<SAMPLES> {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
version: AtomicUsize::new(0),
|
||||||
|
samples: array::from_fn(|_| AtomicU32::new(0.0_f32.to_bits())),
|
||||||
|
#[cfg(test)]
|
||||||
|
bump_after_first_sample_read: std::sync::atomic::AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct RenderReferenceFrameAccumulator<const SAMPLES: usize> {
|
||||||
|
pending: [f32; SAMPLES],
|
||||||
|
pending_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const SAMPLES: usize> RenderReferenceFrameAccumulator<SAMPLES> {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
assert!(
|
||||||
|
SAMPLES > 0,
|
||||||
|
"RenderReferenceFrameAccumulator requires at least one sample"
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
pending: [0.0; SAMPLES],
|
||||||
|
pending_len: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn push_mono_samples(
|
||||||
|
&mut self,
|
||||||
|
mut samples: &[f32],
|
||||||
|
mut publish: impl FnMut(&[f32; SAMPLES]),
|
||||||
|
) {
|
||||||
|
while !samples.is_empty() {
|
||||||
|
let needed = SAMPLES - self.pending_len;
|
||||||
|
let take = needed.min(samples.len());
|
||||||
|
self.pending[self.pending_len..self.pending_len + take]
|
||||||
|
.copy_from_slice(&samples[..take]);
|
||||||
|
self.pending_len += take;
|
||||||
|
samples = &samples[take..];
|
||||||
|
|
||||||
|
if self.pending_len == SAMPLES {
|
||||||
|
publish(&self.pending);
|
||||||
|
self.pending_len = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn pending_len(&self) -> usize {
|
||||||
|
self.pending_len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct RenderReferenceBuffer<const SAMPLES: usize, const SLOTS: usize> {
|
||||||
|
slots: Box<[Slot<SAMPLES>; SLOTS]>,
|
||||||
|
write_idx: AtomicUsize,
|
||||||
|
latest_slot: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const SAMPLES: usize, const SLOTS: usize> RenderReferenceBuffer<SAMPLES, SLOTS> {
|
||||||
|
pub(crate) fn new() -> Arc<Self> {
|
||||||
|
assert!(
|
||||||
|
SLOTS > 0,
|
||||||
|
"RenderReferenceBuffer requires at least one slot"
|
||||||
|
);
|
||||||
|
Arc::new(Self {
|
||||||
|
slots: Box::new(array::from_fn(|_| Slot::new())),
|
||||||
|
write_idx: AtomicUsize::new(0),
|
||||||
|
latest_slot: AtomicUsize::new(NO_LATEST_SLOT),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn write(&self, frame: &[f32; SAMPLES]) {
|
||||||
|
let idx = self.write_idx.load(Ordering::Relaxed) % SLOTS;
|
||||||
|
let slot = &self.slots[idx];
|
||||||
|
// The acquire half keeps payload stores after the odd in-progress marker.
|
||||||
|
let version = slot.version.fetch_add(1, Ordering::AcqRel);
|
||||||
|
debug_assert_eq!(version & 1, 0, "single writer should only enter even slots");
|
||||||
|
for (sample, value) in slot.samples.iter().zip(frame.iter().copied()) {
|
||||||
|
sample.store(value.to_bits(), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
slot.version
|
||||||
|
.store(version.wrapping_add(2) & !1, Ordering::Release);
|
||||||
|
self.latest_slot.store(idx, Ordering::Release);
|
||||||
|
self.write_idx.store((idx + 1) % SLOTS, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_latest(&self) -> [f32; SAMPLES] {
|
||||||
|
let mut out = [0.0_f32; SAMPLES];
|
||||||
|
self.read_latest_into(&mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_latest_into(&self, out: &mut [f32; SAMPLES]) {
|
||||||
|
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||||
|
if idx == NO_LATEST_SLOT {
|
||||||
|
out.fill(0.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let slot = &self.slots[idx];
|
||||||
|
let before = slot.version.load(Ordering::Acquire);
|
||||||
|
if before & 1 == 1 {
|
||||||
|
out.fill(0.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
for (dst, sample) in out.iter_mut().zip(slot.samples.iter()) {
|
||||||
|
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
for (idx, (dst, sample)) in out.iter_mut().zip(slot.samples.iter()).enumerate() {
|
||||||
|
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
|
||||||
|
if idx == 0
|
||||||
|
&& slot
|
||||||
|
.bump_after_first_sample_read
|
||||||
|
.swap(false, Ordering::Relaxed)
|
||||||
|
{
|
||||||
|
slot.version.fetch_add(2, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let after = slot.version.load(Ordering::Acquire);
|
||||||
|
if before != after || after & 1 == 1 {
|
||||||
|
out.fill(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn mark_latest_slot_in_progress_for_test(&self) {
|
||||||
|
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||||
|
assert_ne!(idx, NO_LATEST_SLOT);
|
||||||
|
self.slots[idx].version.fetch_or(1, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn bump_latest_slot_version_after_first_sample_for_test(&self) {
|
||||||
|
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||||
|
assert_ne!(idx, NO_LATEST_SLOT);
|
||||||
|
self.slots[idx]
|
||||||
|
.bump_after_first_sample_read
|
||||||
|
.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{RenderReferenceBuffer, RenderReferenceFrameAccumulator};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_reference_reads_zero_before_first_publish() {
|
||||||
|
let buffer = RenderReferenceBuffer::<4, 2>::new();
|
||||||
|
|
||||||
|
assert_eq!(buffer.read_latest(), [0.0; 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_reference_reader_gets_latest_complete_frame() {
|
||||||
|
let buffer = RenderReferenceBuffer::<4, 3>::new();
|
||||||
|
|
||||||
|
buffer.write(&[1.0, 2.0, 3.0, 4.0]);
|
||||||
|
buffer.write(&[5.0, 6.0, 7.0, 8.0]);
|
||||||
|
|
||||||
|
assert_eq!(buffer.read_latest(), [5.0, 6.0, 7.0, 8.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_reference_writes_wrap_without_returning_stale_frame() {
|
||||||
|
let buffer = RenderReferenceBuffer::<2, 2>::new();
|
||||||
|
|
||||||
|
buffer.write(&[1.0, 2.0]);
|
||||||
|
buffer.write(&[3.0, 4.0]);
|
||||||
|
buffer.write(&[5.0, 6.0]);
|
||||||
|
|
||||||
|
assert_eq!(buffer.read_latest(), [5.0, 6.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_reference_accumulator_publishes_only_complete_frames() {
|
||||||
|
let mut accum = RenderReferenceFrameAccumulator::<4>::new();
|
||||||
|
let mut frames = Vec::new();
|
||||||
|
|
||||||
|
accum.push_mono_samples(&[1.0, 2.0], |frame| frames.push(*frame));
|
||||||
|
|
||||||
|
assert!(frames.is_empty());
|
||||||
|
assert_eq!(accum.pending_len(), 2);
|
||||||
|
|
||||||
|
accum.push_mono_samples(&[3.0, 4.0, 5.0, 6.0, 7.0], |frame| frames.push(*frame));
|
||||||
|
|
||||||
|
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0]]);
|
||||||
|
assert_eq!(accum.pending_len(), 3);
|
||||||
|
|
||||||
|
accum.push_mono_samples(&[8.0], |frame| frames.push(*frame));
|
||||||
|
|
||||||
|
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]]);
|
||||||
|
assert_eq!(accum.pending_len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_reference_reader_rejects_in_progress_slot() {
|
||||||
|
let buffer = RenderReferenceBuffer::<2, 1>::new();
|
||||||
|
|
||||||
|
buffer.write(&[1.0, 2.0]);
|
||||||
|
buffer.mark_latest_slot_in_progress_for_test();
|
||||||
|
|
||||||
|
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_reference_reader_rejects_stale_slot_changed_during_read() {
|
||||||
|
let buffer = RenderReferenceBuffer::<2, 1>::new();
|
||||||
|
|
||||||
|
buffer.write(&[1.0, 2.0]);
|
||||||
|
|
||||||
|
buffer.bump_latest_slot_version_after_first_sample_for_test();
|
||||||
|
|
||||||
|
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "RenderReferenceBuffer requires at least one slot")]
|
||||||
|
fn render_reference_rejects_zero_slots() {
|
||||||
|
let _ = RenderReferenceBuffer::<2, 0>::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -174,7 +174,7 @@ impl AudioCallback for TsPlaybackCallback {
|
|||||||
// in the cpal path, but the upstream design has shipped
|
// in the cpal path, but the upstream design has shipped
|
||||||
// this way for years.
|
// this way for years.
|
||||||
{
|
{
|
||||||
let mut data = self.handler.lock().unwrap();
|
let mut data = self.handler.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let _removed_ids = data.fill_buffer(buffer);
|
let _removed_ids = data.fill_buffer(buffer);
|
||||||
// `_removed_ids` is the list of clients whose stream the
|
// `_removed_ids` is the list of clients whose stream the
|
||||||
// handler just finished draining. We could publish that
|
// handler just finished draining. We could publish that
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ unsafe fn resolve_symbol(name: &'static [u8]) -> Option<*mut c_void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 16 kHz detector backed by Swift `SileroCoreML.SileroVAD`.
|
/// 16 kHz detector backed by Swift Silero CoreML VAD.
|
||||||
|
/// Processes 16 kHz frames and outputs speech probability.
|
||||||
pub struct AppleCoreMlVad {
|
pub struct AppleCoreMlVad {
|
||||||
handle: *mut c_void,
|
handle: *mut c_void,
|
||||||
symbols: AppleSileroSymbols,
|
symbols: AppleSileroSymbols,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
pub mod apple_coreml;
|
pub mod apple_coreml;
|
||||||
pub mod resampler;
|
pub mod resampler;
|
||||||
#[cfg(not(target_os = "ios"))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
pub mod silero_onnx;
|
pub mod silero_onnx;
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
@@ -18,10 +18,11 @@ use crate::frame::{f32_to_i16, i16_to_f32};
|
|||||||
use crate::AudioError;
|
use crate::AudioError;
|
||||||
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
||||||
|
|
||||||
#[cfg(not(target_os = "ios"))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
pub use silero_onnx::SileroOnnxVad;
|
pub use silero_onnx::SileroOnnxVad;
|
||||||
|
|
||||||
/// Voice activity detector output for one 10 ms frame.
|
/// Voice activity detector output for one 10 ms frame.
|
||||||
|
/// Contains speech probability and binary decision.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct VadOutput {
|
pub struct VadOutput {
|
||||||
/// Speech confidence in the inclusive range `[0.0, 1.0]`.
|
/// Speech confidence in the inclusive range `[0.0, 1.0]`.
|
||||||
@@ -36,15 +37,19 @@ pub trait VoiceActivityDetector: Send {
|
|||||||
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput;
|
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Realtime-safe WebRTC VAD used when a model runtime is unavailable.
|
/// Realtime-safe WebRTC VAD fallback when ONNX runtime is unavailable.
|
||||||
|
/// Uses aggressive mode at 48 kHz for voice detection.
|
||||||
pub struct WebRtcFallbackVad {
|
pub struct WebRtcFallbackVad {
|
||||||
vad: webrtc_vad::Vad,
|
vad: webrtc_vad::Vad,
|
||||||
frame_i16: [i16; INPUT_FRAME_10MS],
|
frame_i16: [i16; INPUT_FRAME_10MS],
|
||||||
}
|
}
|
||||||
|
|
||||||
// `webrtc_vad::Vad` owns an FFI pointer and is only touched from the
|
// SAFETY: `webrtc_vad::Vad` wraps an opaque FFI pointer to the WebRTC C VAD
|
||||||
// capture thread after construction. Moving the wrapper between threads is
|
// state. The underlying C struct has no interior mutability that would cause
|
||||||
// safe; sharing it concurrently is not required and not implemented.
|
// data races when moved between threads — `WebRtcVad_Process()` reads/writes
|
||||||
|
// the struct exclusively through the passed pointer with no shared static state.
|
||||||
|
// This wrapper is only used from a single capture thread after construction;
|
||||||
|
// we never share `&WebRtcFallbackVad` across threads (no `Sync` impl).
|
||||||
unsafe impl Send for WebRtcFallbackVad {}
|
unsafe impl Send for WebRtcFallbackVad {}
|
||||||
|
|
||||||
impl Default for WebRtcFallbackVad {
|
impl Default for WebRtcFallbackVad {
|
||||||
@@ -72,8 +77,8 @@ impl VoiceActivityDetector for WebRtcFallbackVad {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wraps any `VoiceActivityDetector` that operates at 16 kHz and
|
/// Wraps any `VoiceActivityDetector` operating at 16 kHz,
|
||||||
/// downsamples 48 kHz input before forwarding.
|
/// downsampling 48 kHz input before forwarding to the detector.
|
||||||
pub struct Resampled16kHzVad<D: VoiceActivityDetector> {
|
pub struct Resampled16kHzVad<D: VoiceActivityDetector> {
|
||||||
inner: D,
|
inner: D,
|
||||||
downsampler: Downsampler48to16,
|
downsampler: Downsampler48to16,
|
||||||
@@ -111,6 +116,9 @@ pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16
|
|||||||
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
|
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
|
||||||
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) static SILERO_MODEL_PATH_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
fn silero_model_path_override() -> &'static RwLock<Option<String>> {
|
fn silero_model_path_override() -> &'static RwLock<Option<String>> {
|
||||||
SILERO_MODEL_PATH_OVERRIDE.get_or_init(|| RwLock::new(None))
|
SILERO_MODEL_PATH_OVERRIDE.get_or_init(|| RwLock::new(None))
|
||||||
}
|
}
|
||||||
@@ -145,6 +153,19 @@ pub fn silero_model_epoch() -> u64 {
|
|||||||
SILERO_MODEL_EPOCH.load(Ordering::Relaxed)
|
SILERO_MODEL_EPOCH.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn clear_silero_model_path_for_test() {
|
||||||
|
set_silero_model_path_for_test(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn set_silero_model_path_for_test(path: Option<String>) {
|
||||||
|
if let Ok(mut guard) = silero_model_path_override().write() {
|
||||||
|
*guard = path;
|
||||||
|
SILERO_MODEL_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Return the expected path of the Silero VAD v6 ONNX model on
|
/// Return the expected path of the Silero VAD v6 ONNX model on
|
||||||
/// supported platforms.
|
/// supported platforms.
|
||||||
/// The model is shipped as a Flutter asset and copied to the app's
|
/// The model is shipped as a Flutter asset and copied to the app's
|
||||||
@@ -234,13 +255,20 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_silero_model_path_rejects_missing_file() {
|
fn set_silero_model_path_rejects_missing_file() {
|
||||||
|
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
clear_silero_model_path_for_test();
|
||||||
|
|
||||||
let result = set_silero_model_path("/definitely/not/a/silero_vad.onnx");
|
let result = set_silero_model_path("/definitely/not/a/silero_vad.onnx");
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
|
clear_silero_model_path_for_test();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_silero_model_path_updates_override_and_epoch() {
|
fn set_silero_model_path_updates_override_and_epoch() {
|
||||||
|
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
clear_silero_model_path_for_test();
|
||||||
|
|
||||||
let path =
|
let path =
|
||||||
std::env::temp_dir().join(format!("chanora_test_silero_{}.onnx", std::process::id()));
|
std::env::temp_dir().join(format!("chanora_test_silero_{}.onnx", std::process::id()));
|
||||||
std::fs::write(&path, b"test").unwrap();
|
std::fs::write(&path, b"test").unwrap();
|
||||||
@@ -250,6 +278,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(silero_model_epoch() > before);
|
assert!(silero_model_epoch() > before);
|
||||||
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
|
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
|
||||||
|
clear_silero_model_path_for_test();
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,6 +361,31 @@ impl SileroOnnxVadWorker {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn stale_test_worker() -> Self {
|
||||||
|
let (tx, rx) = std::sync::mpsc::sync_channel::<SileroFrameMessage>(64);
|
||||||
|
let alive = Arc::new(AtomicBool::new(true));
|
||||||
|
let alive_for_thread = alive.clone();
|
||||||
|
let handle = std::thread::Builder::new()
|
||||||
|
.name("chanora-silero-vad-stale-test".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
while alive_for_thread.load(Ordering::Relaxed) {
|
||||||
|
match rx.recv_timeout(std::time::Duration::from_millis(10)) {
|
||||||
|
Ok(_) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
|
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
Self {
|
||||||
|
tx: Some(tx),
|
||||||
|
latest_probability: Arc::new(AtomicU32::new(0.0_f32.to_bits())),
|
||||||
|
latest_processed_seq: Arc::new(AtomicU64::new(u64::MAX)),
|
||||||
|
alive,
|
||||||
|
handle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort enqueue of a 10 ms frame for background inference.
|
/// Best-effort enqueue of a 10 ms frame for background inference.
|
||||||
pub fn try_send(&self, seq: u64, frame: &[f32; super::resampler::INPUT_FRAME_10MS]) -> bool {
|
pub fn try_send(&self, seq: u64, frame: &[f32; super::resampler::INPUT_FRAME_10MS]) -> bool {
|
||||||
let Some(tx) = &self.tx else {
|
let Some(tx) = &self.tx else {
|
||||||
@@ -393,8 +418,15 @@ impl SileroOnnxVadWorker {
|
|||||||
impl Drop for SileroOnnxVadWorker {
|
impl Drop for SileroOnnxVadWorker {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.alive.store(false, Ordering::Relaxed);
|
self.alive.store(false, Ordering::Relaxed);
|
||||||
|
// Drop the sender first so the worker thread's rx.recv() returns
|
||||||
|
// Err and the loop exits promptly.
|
||||||
let _ = self.tx.take();
|
let _ = self.tx.take();
|
||||||
let _ = self.handle.take();
|
// Join the thread instead of detaching. The channel close
|
||||||
|
// unblocks rx.recv() so the join is bounded; it waits at most
|
||||||
|
// until the current in-flight inference completes.
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/// Diagnostics returned by render downmix helpers.
|
/// Diagnostics returned by render downmix helpers.
|
||||||
|
#[cfg(any(target_os = "ios", test))]
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub(crate) struct RenderDownmixStats {
|
pub(crate) struct RenderDownmixStats {
|
||||||
/// Peak absolute sample magnitude after i16 conversion.
|
/// Peak absolute sample magnitude after i16 conversion.
|
||||||
@@ -7,47 +8,7 @@ pub(crate) struct RenderDownmixStats {
|
|||||||
pub clipped_samples: u64,
|
pub clipped_samples: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downmix interleaved stereo f32 samples into mono i16 samples.
|
|
||||||
///
|
|
||||||
/// The helper is allocation-free and safe for realtime render callbacks.
|
|
||||||
/// If the stereo source is shorter than expected, the remainder of `out`
|
|
||||||
/// is filled with silence.
|
|
||||||
#[cfg(any(target_os = "ios", test))]
|
#[cfg(any(target_os = "ios", test))]
|
||||||
pub(crate) fn downmix_stereo_f32_to_mono_i16(
|
|
||||||
stereo: &[f32],
|
|
||||||
out: &mut [i16],
|
|
||||||
gain: f32,
|
|
||||||
muted: bool,
|
|
||||||
) -> RenderDownmixStats {
|
|
||||||
if muted {
|
|
||||||
out.fill(0);
|
|
||||||
return RenderDownmixStats::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
let available_frames = stereo.len() / 2;
|
|
||||||
if available_frames < out.len() {
|
|
||||||
out.fill(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut peak = 0_u16;
|
|
||||||
let mut clipped_samples = 0_u64;
|
|
||||||
for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) {
|
|
||||||
let mono = (lr[0] + lr[1]) * 0.5 * gain;
|
|
||||||
let clamped = mono.clamp(-1.0, 1.0);
|
|
||||||
if (mono - clamped).abs() > f32::EPSILON {
|
|
||||||
clipped_samples = clipped_samples.saturating_add(1);
|
|
||||||
}
|
|
||||||
let sample = (clamped * i16::MAX as f32) as i16;
|
|
||||||
*dst = sample;
|
|
||||||
peak = peak.max(sample.unsigned_abs());
|
|
||||||
}
|
|
||||||
|
|
||||||
RenderDownmixStats {
|
|
||||||
peak_i16: peak.min(i16::MAX as u16) as i16,
|
|
||||||
clipped_samples,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn downmix_stereo_f32_to_interleaved_i16(
|
pub(crate) fn downmix_stereo_f32_to_interleaved_i16(
|
||||||
stereo: &[f32],
|
stereo: &[f32],
|
||||||
out: &mut [i16],
|
out: &mut [i16],
|
||||||
@@ -122,10 +83,7 @@ pub(crate) fn limit_peak_inplace(samples: &mut [f32], threshold: f32) -> f32 {
|
|||||||
if threshold <= 0.0 || !threshold.is_finite() {
|
if threshold <= 0.0 || !threshold.is_finite() {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
let peak = samples
|
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
|
||||||
.iter()
|
|
||||||
.map(|s| s.abs())
|
|
||||||
.fold(0.0_f32, f32::max);
|
|
||||||
if peak <= threshold {
|
if peak <= threshold {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
@@ -145,7 +103,7 @@ mod tests {
|
|||||||
let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0];
|
let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0];
|
||||||
let mut out = [0_i16; 3];
|
let mut out = [0_i16; 3];
|
||||||
|
|
||||||
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 2.0, false);
|
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 2.0, false);
|
||||||
|
|
||||||
assert_eq!(out[0], i16::MAX);
|
assert_eq!(out[0], i16::MAX);
|
||||||
assert_eq!(out[1], 0);
|
assert_eq!(out[1], 0);
|
||||||
@@ -159,7 +117,7 @@ mod tests {
|
|||||||
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
|
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
|
||||||
let mut out = [123_i16; 2];
|
let mut out = [123_i16; 2];
|
||||||
|
|
||||||
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 1.0, true);
|
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 1.0, true);
|
||||||
|
|
||||||
assert_eq!(out, [0, 0]);
|
assert_eq!(out, [0, 0]);
|
||||||
assert_eq!(stats, RenderDownmixStats::default());
|
assert_eq!(stats, RenderDownmixStats::default());
|
||||||
@@ -234,7 +192,7 @@ mod tests {
|
|||||||
let mut scratch = [1.0_f32, 1.0, -0.5, -0.5, 0.8, 0.8];
|
let mut scratch = [1.0_f32, 1.0, -0.5, -0.5, 0.8, 0.8];
|
||||||
limit_peak_inplace(&mut scratch, 0.95);
|
limit_peak_inplace(&mut scratch, 0.95);
|
||||||
let mut out = [0_i16; 3];
|
let mut out = [0_i16; 3];
|
||||||
let stats = downmix_stereo_f32_to_mono_i16(&scratch, &mut out, 1.0, false);
|
let stats = downmix_stereo_f32_to_interleaved_i16(&scratch, &mut out, 1, 1.0, false);
|
||||||
assert_eq!(stats.clipped_samples, 0);
|
assert_eq!(stats.clipped_samples, 0);
|
||||||
assert!(stats.peak_i16 < i16::MAX);
|
assert!(stats.peak_i16 < i16::MAX);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user