Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
861e915b81 |
-11
@@ -124,16 +124,5 @@ opencode.json
|
||||
# pure waste — the committed binary was ~40 MB per commit).
|
||||
/apps/chanora_flutter/macos/Frameworks/
|
||||
.opencode/
|
||||
.omo/
|
||||
AGENTS.md
|
||||
Screenshot 2026-05-17 at 22.23.07.png
|
||||
|
||||
# Xcode archive / export bundles (generated by Product > Archive > Distribute)
|
||||
**/Chanora */
|
||||
**/*.xcarchive/
|
||||
**/*.ipa
|
||||
**/*.dSYM/
|
||||
|
||||
# macOS release zip bundles produced by local release scripts
|
||||
/chanora-v*.zip
|
||||
|
||||
|
||||
+12
-16
@@ -8,10 +8,8 @@ This project follows a Conventional Commits style workflow.
|
||||
|
||||
The v0.3.0 milestone transitions Chanora from an internal-beta voice
|
||||
prototype to a cross-platform baseline client with event-driven UI,
|
||||
visible per-client audio state, non-self client info parity, and
|
||||
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.
|
||||
per-user audio controls, non-self client info parity, and CI-hardened
|
||||
Android / iOS / macOS / Linux builds.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -19,9 +17,10 @@ required NDK compiler and an authorized ADB target.
|
||||
deltas (client join/leave/move/update, channel add/remove/update)
|
||||
flow through a typed `ProtocolDelta` enum and update the Flutter UI
|
||||
in real time. Channel switching is instant.
|
||||
- **Per-client audio state visibility.** Client rows surface
|
||||
muted/deafened state in avatar badges. Per-user volume UI, persistence,
|
||||
and mixer wiring remain tracked as follow-up work.
|
||||
- **Per-user volume controls.** Each client in the snapshot gets an
|
||||
independent volume slider persisted in the bridge layer. Avatar
|
||||
badges show muted/deafened state. Volume adjustments take effect
|
||||
immediately on the audio mix.
|
||||
- **Non-self client info parity with Qint.** The Info tab now populates
|
||||
connection metadata (name, description, created, last connected,
|
||||
connections, transfer, ping deviation) for other clients via an
|
||||
@@ -30,10 +29,9 @@ required NDK compiler and an authorized ADB target.
|
||||
- **Ping deviation in client profiles.** `ping_deviation_milliseconds`
|
||||
propagated from protocol DTO through bridge API to Dart, with a
|
||||
conditional l10n row in the client info sheet (en + zh).
|
||||
- **Apple CoreML Silero VAD scaffolding/assets** for iOS / macOS when
|
||||
the private `silero-coreml` SwiftPM package is available. Product
|
||||
`VoiceActivity` remains reserved/disabled per DEC-030 until a later
|
||||
baseline enables and verifies it.
|
||||
- **Apple CoreML Silero VAD** as the preferred voice activity detector
|
||||
on iOS / macOS when the private `silero-coreml` SwiftPM submodule is
|
||||
available. WebRTC VAD remains the runtime fallback.
|
||||
- **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
|
||||
lookups and `ts3server://` URI handling.
|
||||
- **Per-ABI Android APK splitting.** `flutter build apk
|
||||
@@ -52,9 +50,8 @@ required NDK compiler and an authorized ADB target.
|
||||
- **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
|
||||
serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
|
||||
audio startup.
|
||||
- **Linux native audio path promoted** with ONNX Runtime VAD assets
|
||||
bundled for future `VoiceActivity` work. Desktop voice I/O works on
|
||||
PipeWire / PulseAudio; product `VoiceActivity` remains disabled.
|
||||
- **Linux native audio path promoted** with ONNX Runtime bundled for
|
||||
VAD. Desktop voice I/O works on PipeWire / PulseAudio.
|
||||
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
|
||||
startup permission flow, and system back-button integration.
|
||||
- **`SnapshotChanged` event removed.** Replaced by the typed delta
|
||||
@@ -62,8 +59,7 @@ required NDK compiler and an authorized ADB target.
|
||||
Flutter).
|
||||
- **Prefetch crate renamed** from the PoC-era name to
|
||||
`chanora_prefetch`. All docs, specs, and code updated.
|
||||
- **Flutter app version/build bumped to `0.3.0+100`.** Rust workspace
|
||||
packages remain versioned separately at `0.2.0-beta.1`.
|
||||
- **Build number bumped to 76.**
|
||||
- **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
Generated
+75
-73
@@ -70,15 +70,6 @@ dependencies = [
|
||||
"backtrace",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloca"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.11.0"
|
||||
@@ -437,12 +428,12 @@ dependencies = [
|
||||
"dhat",
|
||||
"dispatch2",
|
||||
"futures-util",
|
||||
"jni 0.22.4",
|
||||
"jni 0.21.1",
|
||||
"ndarray",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
"ort",
|
||||
"rand 0.10.1",
|
||||
"rand 0.8.6",
|
||||
"rustfft",
|
||||
"sdl2",
|
||||
"serde_json",
|
||||
@@ -453,7 +444,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"tsclientlib",
|
||||
"webrtc-vad",
|
||||
"windows",
|
||||
"windows 0.54.0",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
@@ -532,7 +523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chanora_resolver"
|
||||
version = "0.2.0-beta.1"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hickory-resolver",
|
||||
@@ -726,15 +717,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.18.0"
|
||||
version = "0.17.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9dd2b2151ebb4d5866c804d89fe28244bfb6b74481b9b4d406e4ec4d7f88ce5"
|
||||
checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"block2",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni 0.22.4",
|
||||
"jni 0.21.1",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
@@ -749,9 +739,10 @@ dependencies = [
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -774,24 +765,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.8.2"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
|
||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||
dependencies = [
|
||||
"alloca",
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"itertools 0.13.0",
|
||||
"is-terminal",
|
||||
"itertools 0.10.5",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
"page_size",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
@@ -799,12 +791,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.8.2"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools 0.13.0",
|
||||
"itertools 0.10.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1993,7 +1985,7 @@ dependencies = [
|
||||
"socket2",
|
||||
"widestring",
|
||||
"windows-registry",
|
||||
"windows-result",
|
||||
"windows-result 0.4.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -2007,10 +1999,21 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
@@ -2235,9 +2238,12 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.6.0"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
|
||||
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
@@ -2534,7 +2540,6 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||
dependencies = [
|
||||
"bitflags 2.12.1",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -2747,16 +2752,6 @@ dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "page_size"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -3503,9 +3498,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sdl2"
|
||||
version = "0.38.0"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef"
|
||||
checksum = "3b498da7d14d1ad6c839729bd4ad6fc11d90a57583605f3b4df2cd709a9cd380"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"lazy_static",
|
||||
@@ -3515,9 +3510,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sdl2-sys"
|
||||
version = "0.38.0"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0"
|
||||
checksum = "951deab27af08ed9c6068b7b0d05a93c91f0a8eb16b6b816a5e73452a43521d3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
@@ -4773,22 +4768,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -4799,10 +4778,14 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
@@ -4811,7 +4794,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
@@ -4822,7 +4805,17 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4834,7 +4827,7 @@ dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
@@ -4844,7 +4837,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
@@ -4883,7 +4876,7 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
@@ -4894,10 +4887,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
||||
-14
@@ -82,17 +82,3 @@ tsproto-types = { git = "https://github.com/EdisonJwa/tsclientlib.git", branch =
|
||||
|
||||
[patch.crates-io]
|
||||
cmake = { git = "https://github.com/pr2502/cmake-rs", rev = "bdad5edc569d82151922c5c6c4685b1563f12aa1" }
|
||||
|
||||
# Apple-only DWARF emission for archive validation lives in the iOS and
|
||||
# macOS chanora_bridge podspecs (apps/chanora_flutter/{ios,macos}/
|
||||
# chanora_bridge.podspec) as per-build environment overrides:
|
||||
#
|
||||
# CARGO_PROFILE_RELEASE_DEBUG=true
|
||||
# CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off
|
||||
# CARGO_PROFILE_RELEASE_STRIP=false
|
||||
#
|
||||
# This keeps Android, Linux, and Windows release binaries on the cargo
|
||||
# default release profile (no DWARF, no extra ~10MB symbol payload).
|
||||
# Apple builds need the DWARF so dsymutil can emit a usable
|
||||
# chanora_bridge.framework.dSYM that the archive validator accepts.
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ Flutter UI + Rust Core + tsclientlib
|
||||
|
||||
## Status
|
||||
|
||||
Chanora is currently a baseline-candidate Flutter + Rust workspace. It is not production-ready and is not approved for public or store release.
|
||||
Chanora is currently in early planning and baseline-candidate design.
|
||||
|
||||
```text
|
||||
Current documentation baseline: v0.9.x document set
|
||||
Current documentation baseline: v0.9.2
|
||||
Current status: Baseline Candidate
|
||||
Implementation status: Not production-ready
|
||||
```
|
||||
@@ -25,7 +25,7 @@ Implementation status: Not production-ready
|
||||
The current engineering focus is:
|
||||
|
||||
- defining the system and software architecture;
|
||||
- hardening the Flutter + Rust application structure;
|
||||
- preparing the Flutter + Rust application structure;
|
||||
- validating TeamSpeak-compatible protocol integration through `tsclientlib`;
|
||||
- defining cross-platform audio behavior;
|
||||
- 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 |
|
||||
| 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 |
|
||||
| Android runtime target | Android API 28+ per DEC-004, SysRS-288, SRS-187, and Gradle `minSdk = 28` |
|
||||
| Android runtime target | Android API 24+ unless Flutter, plugin, audio, or product constraints require raising it |
|
||||
| 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.
|
||||
@@ -230,7 +230,7 @@ docs/
|
||||
aspice-swe2-swe3-integration-note.md
|
||||
```
|
||||
|
||||
Implementation source folders are present in this workspace. The current high-level structure is:
|
||||
Implementation source folders may be added later. A likely structure is:
|
||||
|
||||
```text
|
||||
apps/
|
||||
@@ -248,7 +248,7 @@ crates/
|
||||
chanora_bridge/
|
||||
```
|
||||
|
||||
The exact implementation layout may continue to evolve as maintainability reviews split or merge Modules, but the repository scaffold exists.
|
||||
The exact implementation layout should be finalized when the repository scaffold is created.
|
||||
|
||||
---
|
||||
|
||||
@@ -395,7 +395,9 @@ docs/governance/git-commit-message-convention.md
|
||||
|
||||
## Development
|
||||
|
||||
Common local commands include:
|
||||
Implementation commands will be added after the repository scaffold is finalized.
|
||||
|
||||
Expected future commands may include:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
@@ -405,7 +407,7 @@ cargo clippy
|
||||
cargo fmt
|
||||
```
|
||||
|
||||
Android runtime success also requires an available Android NDK toolchain and an authorized device or emulator for build/install/smoke verification.
|
||||
Do not treat these as authoritative until the actual Flutter/Rust workspace has been created.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ android {
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -199,7 +198,6 @@ android {
|
||||
// armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB.
|
||||
dependencies {
|
||||
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,3 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:keep="@drawable/ic_chanora_notification" />
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>ad-hoc</string>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
<key>teamID</key>
|
||||
<string>ZNVDEVDRX3</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>app.teamspeak.chanora</key>
|
||||
<string>Chanora_Ad_Hoc</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,6 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
// Mirror Release.xcconfig (see explanation there).
|
||||
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
|
||||
STRIP_STYLE = non-global
|
||||
|
||||
@@ -1,27 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.release.xcconfig"
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
// Force the linker to retain Swift @_cdecl symbols that the chanora_bridge
|
||||
// Rust framework resolves at runtime via dlsym(RTLD_DEFAULT). Two flags per
|
||||
// symbol, intentionally redundant:
|
||||
//
|
||||
// -u _sym marks the symbol as force-undefined at link time,
|
||||
// which keeps the object that defines it from being
|
||||
// dropped and prevents dead-strip from removing the
|
||||
// definition. This is the load-bearing flag.
|
||||
// -exported_symbol _sym re-exports the symbol in the final binary's
|
||||
// dynamic symbol table so dlsym(RTLD_DEFAULT) can
|
||||
// find it from the Rust framework at runtime.
|
||||
//
|
||||
// Without -u, Xcode Archive's -dead_strip (WMO + LTO) can remove the
|
||||
// symbol before the export list is applied, and CoreML VAD silently falls
|
||||
// back to WebRTC on TestFlight / App Store. The Swift-side static
|
||||
// `unsafeBitCast` references in SileroCoreMLBridge.swift are belt-and-
|
||||
// suspenders defense-in-depth, NOT the primary guarantee.
|
||||
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 = all` (Xcode default for archive installs) runs `strip` without
|
||||
// `-x`, which removes even the global @_cdecl symbols the linker exported above
|
||||
// via -exported_symbol. `non-global` runs `strip -x`, preserving globals so the
|
||||
// Rust framework's dlsym(RTLD_DEFAULT) can find them. 264-byte cost in the app.
|
||||
STRIP_STYLE = non-global
|
||||
|
||||
@@ -27,7 +27,7 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
|
||||
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
target 'Chanora' do
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
|
||||
# Chanora Rust bridge as a vendored framework. The podspec runs
|
||||
|
||||
@@ -23,11 +23,11 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/haptic_kit/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
chanora_bridge: 26252acdf9ca660ce9c132ad25cd5ad5af467b16
|
||||
chanora_bridge: 2ed7c2ba427fab135dd9eab66c507b09cfee113a
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
||||
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
||||
|
||||
PODFILE CHECKSUM: 85b93b53f958f1ff700a147e9da4374c8b1c6970
|
||||
PODFILE CHECKSUM: e2123068539aeb66d53dc1612b383d13f489ede2
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0B4754099284EEDCD859A973 /* Pods_Runner.framework */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
3EF79A791760D95CE0F41CFF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
8C5000012DD0000000000001 /* SileroCoreMLBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */; };
|
||||
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */ = {isa = PBXBuildFile; productRef = 8C5000032DD0000000000001 /* SileroCoreML */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
C8BACE02E6EE5F840EE3F174 /* Pods_Chanora.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */; };
|
||||
FD3C80659716BF7A0C95C7AF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -48,10 +48,10 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
0B4754099284EEDCD859A973 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
2EA1142FBFD36E2ED564A5AA /* Pods-Chanora.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chanora.release.xcconfig"; path = "Target Support Files/Pods-Chanora/Pods-Chanora.release.xcconfig"; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
@@ -60,23 +60,20 @@
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SileroCoreMLBridge.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
7E043103010958FC2C6CA47F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
89E01DD0E6B92DA93A02E9D6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SileroCoreMLBridge.swift; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Chanora.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Chanora.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
A23C02505CD7E5092CA7958C /* Pods-Chanora.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chanora.profile.xcconfig"; path = "Target Support Files/Pods-Chanora/Pods-Chanora.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
C10A61C706CAF223682AC397 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Chanora.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
EAE6402BFC041304D1D0896D /* Pods-Chanora.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chanora.debug.xcconfig"; path = "Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -86,7 +83,7 @@
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */,
|
||||
C8BACE02E6EE5F840EE3F174 /* Pods_Chanora.framework in Frameworks */,
|
||||
1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -112,8 +109,8 @@
|
||||
4351F25046559EFA4C03047A /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0B4754099284EEDCD859A973 /* Pods_Runner.framework */,
|
||||
63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */,
|
||||
DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -127,9 +124,6 @@
|
||||
73171B86DD76CC3E5A58E160 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */,
|
||||
076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
EAE6402BFC041304D1D0896D /* Pods-Chanora.debug.xcconfig */,
|
||||
2EA1142FBFD36E2ED564A5AA /* Pods-Chanora.release.xcconfig */,
|
||||
A23C02505CD7E5092CA7958C /* Pods-Chanora.profile.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
@@ -161,7 +155,7 @@
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Chanora.app */,
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
@@ -207,15 +201,14 @@
|
||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Chanora */ = {
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Chanora" */;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
CA110001000000000000A100 /* Verify Silero Exports */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
@@ -225,13 +218,13 @@
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Chanora;
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
8C5000032DD0000000000001 /* SileroCoreML */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Chanora.app */;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@@ -264,14 +257,14 @@
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Chanora */,
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
};
|
||||
@@ -344,15 +337,15 @@
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks.sh\"\n";
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */ = {
|
||||
@@ -370,7 +363,7 @@
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Chanora-checkManifestLockResult.txt",
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
@@ -392,21 +385,6 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
CA110001000000000000A100 /* Verify Silero Exports */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Verify Silero Exports";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${SRCROOT}/../scripts/verify_silero_exports.sh\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -434,7 +412,7 @@
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 97C146ED1CF9000F007C117D /* Chanora */;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
@@ -518,23 +496,18 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 101;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 349G7M4TQQ;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Chanora_iOS_Ad Hoc";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Chanora_iOS_Ad Hoc";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
@@ -555,7 +528,7 @@
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -571,7 +544,7 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@@ -587,7 +560,7 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
@@ -709,23 +682,18 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 101;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 349G7M4TQQ;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = Chanora_ios_Development;
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = Chanora_ios_Development;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -740,23 +708,18 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 101;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 349G7M4TQQ;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Chanora_App Store";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Chanora_App Store";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
@@ -786,7 +749,7 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Chanora" */ = {
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147061CF9000F007C117D /* Debug */,
|
||||
@@ -799,26 +762,26 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = "../../../silero-coreml";
|
||||
relativePath = ../../../silero-coreml;
|
||||
};
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
8C5000032DD0000000000001 /* SileroCoreML */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
|
||||
productName = SileroCoreML;
|
||||
};
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.7">
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
@@ -15,8 +15,8 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Chanora.app"
|
||||
BlueprintName = "Chanora"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
@@ -33,8 +33,8 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Chanora.app"
|
||||
BlueprintName = "Chanora"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
@@ -50,8 +50,8 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Chanora.app"
|
||||
BlueprintName = "Chanora"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
@@ -86,8 +86,8 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Chanora.app"
|
||||
BlueprintName = "Chanora"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
@@ -103,8 +103,8 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Chanora.app"
|
||||
BlueprintName = "Chanora"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
|
||||
@@ -6,54 +6,95 @@ import AVFoundation
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
private var iosAudioLifecycleChannel: 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(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
ChanoraSileroSelfTest.run()
|
||||
// Configure the iOS AVAudioSession **category + mode** at
|
||||
// app-launch time, but DEFER setActive(true) until the scene
|
||||
// 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
|
||||
// state needs to be deferred. We listen for
|
||||
// didBecomeActiveNotification and activate then. Most
|
||||
// production iOS voice apps (Discord, Zoom, FaceTime) follow
|
||||
// this same shape.
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.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 {
|
||||
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
|
||||
}
|
||||
|
||||
// AVAudioSession lifecycle policy (DEC-2026-06-08, supersedes
|
||||
// the launch-time .playAndRecord setup):
|
||||
//
|
||||
// At launch we set the category to .ambient and leave the
|
||||
// session INACTIVE — matching the Telegram / Signal / Discord /
|
||||
// Element / Jitsi pattern and Apple's guidance that "a VoIP
|
||||
// app's audio session should not be active" while idle.
|
||||
// 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 {
|
||||
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||
logAudioSessionState(context: "launch-ambient")
|
||||
} catch {
|
||||
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(
|
||||
self,
|
||||
@@ -79,60 +120,37 @@ import AVFoundation
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
/// Activate the VoIP audio session. Called from Dart via the
|
||||
/// `chanora/ios_audio_session` channel before a voice channel join
|
||||
/// starts VoiceProcessingIO. Configures
|
||||
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
|
||||
/// (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() {
|
||||
/// Called by `didBecomeActiveNotification` (cold-launch settle +
|
||||
/// every resume-from-background). Activates the AVAudioSession.
|
||||
/// Repeated activation is a no-op when the session is already
|
||||
/// active so this is safe to call on every foreground.
|
||||
@objc private func activateAudioSession() {
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
||||
)
|
||||
try session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setPreferredSampleRate(48000.0)
|
||||
try session.setActive(true, options: [])
|
||||
voiceSessionActive = true
|
||||
logAudioSessionState(context: "activateVoiceSession")
|
||||
let ins = session.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
||||
NSLog("chanora_flutter: AVAudioSession activated on foreground")
|
||||
// Read back the ACTUAL session state. preferredSampleRate /
|
||||
// preferredIOBufferDuration are hints; iOS may pick something
|
||||
// else depending on hardware + currently-engaged effects.
|
||||
// Without these we can't tell whether VPIO is running at
|
||||
// 48 kHz mono (what our render callback assumes) or at e.g.
|
||||
// 44.1 kHz (which would explain the user's broken playback
|
||||
// \u2014 our render callback would be writing samples at the
|
||||
// wrong rate, causing pitch + timing artifacts).
|
||||
logAudioSessionState(context: "setActive")
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||
NSLog(
|
||||
"chanora_flutter: voice session active: " +
|
||||
"sampleRate=\(session.sampleRate) " +
|
||||
"ioBufferDuration=\(String(format: "%.4f", session.ioBufferDuration)) " +
|
||||
"inputs=[\(ins)] outputVolume=\(session.outputVolume)"
|
||||
"chanora_flutter: AVAudioSession actual: " +
|
||||
"sampleRate=\(s.sampleRate) " +
|
||||
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
|
||||
"inputs=[\(ins)] " +
|
||||
"outputVolume=\(s.outputVolume)"
|
||||
)
|
||||
} catch {
|
||||
NSLog("chanora_flutter: activateVoiceSession failed: \(error)")
|
||||
NSLog("chanora_flutter: AVAudioSession setActive 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
|
||||
/// SDD-098 compliance. Called after both setCategory and setActive
|
||||
/// to verify that the session accepted the requested configuration.
|
||||
@@ -197,30 +215,25 @@ import AVFoundation
|
||||
}
|
||||
|
||||
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
||||
NSLog("chanora_flutter: media services reset voiceActive=\(voiceSessionActive)")
|
||||
if voiceSessionActive {
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
||||
)
|
||||
try session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setPreferredSampleRate(48000.0)
|
||||
try session.setActive(true, options: [])
|
||||
logAudioSessionState(context: "mediaServicesWereReset-voip")
|
||||
} 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)")
|
||||
}
|
||||
NSLog("chanora_flutter: media services reset")
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||
)
|
||||
try session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setPreferredSampleRate(48000.0)
|
||||
try session.setActive(true, options: [])
|
||||
logAudioSessionState(context: "mediaServicesWereReset")
|
||||
} catch {
|
||||
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild 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)
|
||||
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
||||
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
||||
@@ -252,26 +265,6 @@ import AVFoundation
|
||||
name: "chanora/ios_platform",
|
||||
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
|
||||
switch call.method {
|
||||
case "getMicrophonePermissionState":
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<!-- Opt into ProMotion / high-refresh-rate CADisplayLink ranges on
|
||||
supported iPhones. Flutter's iOS embedder reads this key; no
|
||||
additional Flutter package is required for dynamic refresh. -->
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -24,18 +27,10 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Chanora needs local network access to connect to your voice servers.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<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>
|
||||
<string>Chanora needs microphone access so you can talk on your TeamSpeak-compatible voice server.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
@@ -63,8 +58,8 @@
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -82,5 +77,9 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import CoreML
|
||||
import Darwin
|
||||
import Foundation
|
||||
import SileroCoreML
|
||||
|
||||
@@ -97,136 +96,3 @@ public func chanoraSileroVadFreeString(_ string: UnsafeMutablePointer<CChar>?) {
|
||||
guard let string else { return }
|
||||
free(string)
|
||||
}
|
||||
|
||||
@objc public final class ChanoraSileroSelfTest: NSObject {
|
||||
// Validates the same code path the Rust framework uses: dlsym(RTLD_DEFAULT) for all
|
||||
// six @_cdecl symbols, then exercises create -> reset -> process -> destroy. Catches
|
||||
// the dead-strip / linker-export class of bug that broke TestFlight; calling the Swift
|
||||
// functions directly would mask it because direct calls bypass the dynamic symbol table.
|
||||
@objc public static func run() {
|
||||
let started = DispatchTime.now()
|
||||
|
||||
// Static linker references: keep the Swift compiler / linker from
|
||||
// dead-stripping the @_cdecl symbols under Whole-Module-Optimization
|
||||
// + LTO in Archive builds. dlsym(RTLD_DEFAULT) below does NOT count
|
||||
// as a static reference for the dead-stripper — these `_ = ` lines
|
||||
// do. Without them, TestFlight builds shipped without the symbols
|
||||
// even though Debug builds (no LTO) worked.
|
||||
//
|
||||
// The `withoutActuallyEscaping` dance prevents the optimizer from
|
||||
// proving the references are unused: assigning the function value
|
||||
// to a `@convention(c)` typealias forces address-taken semantics.
|
||||
_ = unsafeBitCast(
|
||||
chanoraSileroVadCreate as @convention(c) () -> UnsafeMutableRawPointer?,
|
||||
to: UnsafeRawPointer.self,
|
||||
)
|
||||
_ = unsafeBitCast(
|
||||
chanoraSileroVadDestroy as @convention(c) (UnsafeMutableRawPointer?) -> Void,
|
||||
to: UnsafeRawPointer.self,
|
||||
)
|
||||
_ = unsafeBitCast(
|
||||
chanoraSileroVadReset as @convention(c) (UnsafeMutableRawPointer?) -> Int32,
|
||||
to: UnsafeRawPointer.self,
|
||||
)
|
||||
_ = unsafeBitCast(
|
||||
chanoraSileroVadProcess
|
||||
as @convention(c) (
|
||||
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int,
|
||||
UnsafeMutablePointer<Float>?
|
||||
) -> Int32,
|
||||
to: UnsafeRawPointer.self,
|
||||
)
|
||||
_ = unsafeBitCast(
|
||||
chanoraSileroVadLastError as @convention(c) () -> UnsafeMutablePointer<CChar>?,
|
||||
to: UnsafeRawPointer.self,
|
||||
)
|
||||
_ = unsafeBitCast(
|
||||
chanoraSileroVadFreeString as @convention(c) (UnsafeMutablePointer<CChar>?) -> Void,
|
||||
to: UnsafeRawPointer.self,
|
||||
)
|
||||
|
||||
typealias CreateFn = @convention(c) () -> UnsafeMutableRawPointer?
|
||||
typealias DestroyFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
|
||||
typealias ResetFn = @convention(c) (UnsafeMutableRawPointer?) -> Int32
|
||||
typealias ProcessFn = @convention(c) (
|
||||
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int, UnsafeMutablePointer<Float>?
|
||||
) -> Int32
|
||||
typealias LastErrorFn = @convention(c) () -> UnsafeMutablePointer<CChar>?
|
||||
typealias FreeStringFn = @convention(c) (UnsafeMutablePointer<CChar>?) -> Void
|
||||
|
||||
func resolve<T>(_ name: String, as type: T.Type) -> T? {
|
||||
guard let raw = dlsym(UnsafeMutableRawPointer(bitPattern: -2), name) else {
|
||||
return nil
|
||||
}
|
||||
return unsafeBitCast(raw, to: type)
|
||||
}
|
||||
|
||||
let names = [
|
||||
"chanora_silero_vad_create",
|
||||
"chanora_silero_vad_destroy",
|
||||
"chanora_silero_vad_reset",
|
||||
"chanora_silero_vad_process",
|
||||
"chanora_silero_vad_last_error",
|
||||
"chanora_silero_vad_free_string",
|
||||
]
|
||||
let missing = names.filter { dlsym(UnsafeMutableRawPointer(bitPattern: -2), $0) == nil }
|
||||
if !missing.isEmpty {
|
||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED dlsym missing=\(missing.joined(separator: ","))")
|
||||
return
|
||||
}
|
||||
|
||||
guard
|
||||
let create = resolve("chanora_silero_vad_create", as: CreateFn.self),
|
||||
let destroy = resolve("chanora_silero_vad_destroy", as: DestroyFn.self),
|
||||
let reset = resolve("chanora_silero_vad_reset", as: ResetFn.self),
|
||||
let process = resolve("chanora_silero_vad_process", as: ProcessFn.self),
|
||||
let lastError = resolve("chanora_silero_vad_last_error", as: LastErrorFn.self),
|
||||
let freeString = resolve("chanora_silero_vad_free_string", as: FreeStringFn.self)
|
||||
else {
|
||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED unsafeBitCast resolution")
|
||||
return
|
||||
}
|
||||
|
||||
func readError() -> String {
|
||||
guard let ptr = lastError() else { return "unknown" }
|
||||
let msg = String(cString: ptr)
|
||||
freeString(ptr)
|
||||
return msg
|
||||
}
|
||||
|
||||
guard let handle = create() else {
|
||||
let elapsedMs = elapsedMs(since: started)
|
||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED at create err=\(readError()) elapsed_ms=\(elapsedMs)")
|
||||
return
|
||||
}
|
||||
|
||||
let resetRc = reset(handle)
|
||||
if resetRc != 0 {
|
||||
destroy(handle)
|
||||
let elapsedMs = elapsedMs(since: started)
|
||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED at reset rc=\(resetRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
|
||||
return
|
||||
}
|
||||
|
||||
let chunkSize = SileroVADRunner.chunkSize
|
||||
var probability: Float = 0
|
||||
let samples = [Float](repeating: 0, count: chunkSize)
|
||||
let processRc = samples.withUnsafeBufferPointer { buf -> Int32 in
|
||||
process(handle, buf.baseAddress, chunkSize, &probability)
|
||||
}
|
||||
|
||||
destroy(handle)
|
||||
|
||||
let elapsedMs = elapsedMs(since: started)
|
||||
if processRc == 0 {
|
||||
NSLog("chanora_flutter: SileroCoreML self-test OK probability=\(probability) elapsed_ms=\(elapsedMs)")
|
||||
} else {
|
||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED at process rc=\(processRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func elapsedMs(since start: DispatchTime) -> String {
|
||||
let ns = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds
|
||||
return String(format: "%.1f", Double(ns) / 1_000_000.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,13 +85,6 @@ Pod::Spec.new do |s|
|
||||
}
|
||||
CARGO_BIN="$(find_cargo)"
|
||||
RUSTC_BIN="$(find_rustc)"
|
||||
|
||||
# Prepend Homebrew's bin dir to PATH so `cmake` (used by
|
||||
# audiopus_sys's libopus source build) is found. Xcode's
|
||||
# script_phase PATH sanitisation strips /opt/homebrew/bin,
|
||||
# which on Apple Silicon hosts is where Homebrew tools live.
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
|
||||
echo "[chanora_bridge.podspec] cargo build aarch64-apple-ios"
|
||||
cd "$REPO_ROOT"
|
||||
HOME="$USER_HOME" \\
|
||||
@@ -102,9 +95,6 @@ Pod::Spec.new do |s|
|
||||
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
|
||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
|
||||
CARGO_PROFILE_RELEASE_DEBUG=true \\
|
||||
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
|
||||
CARGO_PROFILE_RELEASE_STRIP=false \\
|
||||
"$CARGO_BIN" build --release --target aarch64-apple-ios -p chanora_bridge
|
||||
|
||||
if [ ! -f "$BRIDGE" ]; then
|
||||
@@ -139,21 +129,7 @@ PLIST
|
||||
|
||||
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
||||
"$FW/chanora_bridge"
|
||||
|
||||
# Generate the framework's dSYM bundle. Apple's archive validator
|
||||
# rejects uploads when an embedded framework has no matching dSYM
|
||||
# (UUID lookup miss in the archive's dSYMs/ folder), which is the
|
||||
# failure mode that produced this prepare_command in the first
|
||||
# place. dsymutil reads the DWARF that cargo emitted (enabled by
|
||||
# [profile.release] debug = true at the workspace root) and writes
|
||||
# chanora_bridge.framework.dSYM next to the framework. We then
|
||||
# strip the in-framework binary so the shipped app stays slim —
|
||||
# the symbols live exclusively in the dSYM bundle, which is the
|
||||
# layout xcodebuild -exportArchive and App Store Connect expect.
|
||||
rm -rf "$FW.dSYM"
|
||||
xcrun dsymutil "$FW/chanora_bridge" -o "$FW.dSYM"
|
||||
xcrun strip -S -x "$FW/chanora_bridge"
|
||||
echo "[chanora_bridge.podspec] framework + dSYM ready at $FW"
|
||||
echo "[chanora_bridge.podspec] framework ready at $FW"
|
||||
SCRIPT
|
||||
|
||||
# Pod CocoaPods picks this up; the framework gets embedded into
|
||||
@@ -209,13 +185,6 @@ PLIST
|
||||
}
|
||||
CARGO_BIN="$(find_cargo)"
|
||||
RUSTC_BIN="$(find_rustc)"
|
||||
|
||||
# Prepend Homebrew's bin dir to PATH so `cmake` (used by
|
||||
# audiopus_sys's libopus source build) is found. Xcode's
|
||||
# script_phase PATH sanitisation strips /opt/homebrew/bin,
|
||||
# which on Apple Silicon hosts is where Homebrew tools live.
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
|
||||
if [ "${PLATFORM_NAME:-iphoneos}" = "iphonesimulator" ]; then
|
||||
RUST_TARGET="aarch64-apple-ios-sim"
|
||||
SUPPORTED_PLATFORM="iPhoneSimulator"
|
||||
@@ -234,9 +203,6 @@ PLIST
|
||||
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
|
||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
|
||||
CARGO_PROFILE_RELEASE_DEBUG=true \\
|
||||
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
|
||||
CARGO_PROFILE_RELEASE_STRIP=false \\
|
||||
"$CARGO_BIN" build --release --target "$RUST_TARGET" -p chanora_bridge
|
||||
|
||||
cd "$REPO_ROOT/apps/chanora_flutter/ios"
|
||||
@@ -244,19 +210,15 @@ PLIST
|
||||
|
||||
# Skip the wrap step if the framework's binary is already
|
||||
# up-to-date with the cargo output (fast no-op on incremental
|
||||
# builds where Rust didn't change). We still publish the dSYM
|
||||
# into DWARF_DSYM_FOLDER_PATH below so archive builds always
|
||||
# have the symbols, even when the framework itself is cached.
|
||||
FW_UP_TO_DATE=0
|
||||
# builds where Rust didn't change).
|
||||
if [ -f "$FW/chanora_bridge" ] && [ "$FW/chanora_bridge" -nt "$BRIDGE" ]; then
|
||||
echo "[chanora_bridge script_phase] framework already up-to-date"
|
||||
FW_UP_TO_DATE=1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$FW_UP_TO_DATE" = 0 ]; then
|
||||
mkdir -p "$FW"
|
||||
cp "$BRIDGE" "$FW/chanora_bridge"
|
||||
cat > "$FW/Info.plist" <<PLIST
|
||||
mkdir -p "$FW"
|
||||
cp "$BRIDGE" "$FW/chanora_bridge"
|
||||
cat > "$FW/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
@@ -272,28 +234,9 @@ PLIST
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
||||
"$FW/chanora_bridge"
|
||||
rm -rf "$FW.dSYM"
|
||||
xcrun dsymutil "$FW/chanora_bridge" -o "$FW.dSYM"
|
||||
xcrun strip -S -x "$FW/chanora_bridge"
|
||||
echo "[chanora_bridge script_phase] framework refreshed (with dSYM)"
|
||||
fi
|
||||
|
||||
# Publish the dSYM into Xcode's archive dSYM folder on every
|
||||
# build (cached or not). Without this the archive validator
|
||||
# fails with "archive did not include a dSYM for the
|
||||
# chanora_bridge.framework with the UUIDs [<uuid>]" and the
|
||||
# IPA cannot be uploaded to App Store Connect / TestFlight.
|
||||
# ${DWARF_DSYM_FOLDER_PATH} resolves to <ARCHIVE>/dSYMs for
|
||||
# archive builds and <BUILT_PRODUCTS_DIR> otherwise; both paths
|
||||
# are the ones xcodebuild scans when collecting symbols.
|
||||
if [ -n "${DWARF_DSYM_FOLDER_PATH:-}" ] && [ -d "$FW.dSYM" ]; then
|
||||
mkdir -p "$DWARF_DSYM_FOLDER_PATH"
|
||||
rm -rf "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
|
||||
cp -R "$FW.dSYM" "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
|
||||
echo "[chanora_bridge script_phase] dSYM published to $DWARF_DSYM_FOLDER_PATH"
|
||||
fi
|
||||
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
||||
"$FW/chanora_bridge"
|
||||
echo "[chanora_bridge script_phase] framework refreshed"
|
||||
SCRIPT
|
||||
:execution_position => :before_compile,
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Canonical layout breakpoints for Chanora.
|
||||
//
|
||||
// Aligned with Material 3 adaptive layout guidance:
|
||||
// compact < 600dp — phone, narrow tablet
|
||||
// medium 600–1023 — tablet portrait, small desktop window
|
||||
// expanded ≥ 1024dp — desktop, tablet landscape
|
||||
//
|
||||
// 1024dp was chosen as the expanded threshold based on production app
|
||||
// research: Discord (member list at 1024px), Mattermost (RHS docked at
|
||||
// ≥ 1024px), and Rocket.Chat (contextual bar persistent at lg/1024px).
|
||||
|
||||
/// Canonical breakpoint thresholds in logical pixels.
|
||||
///
|
||||
/// Use these instead of hardcoded pixel values in layout decisions.
|
||||
/// Migrate existing `_wideBreakpoint` / `_chatMobileBreakpoint` references
|
||||
/// to these named constants.
|
||||
class ChanoraBreakpoints {
|
||||
ChanoraBreakpoints._();
|
||||
|
||||
/// Width at which the layout switches from compact to medium.
|
||||
/// Below this: single-column mobile layout.
|
||||
/// At/above: two-panel side-by-side layout.
|
||||
static const double medium = 600;
|
||||
|
||||
/// Width at which the layout switches from medium to expanded.
|
||||
/// Below this: chat opens as a pushed route.
|
||||
/// At/above: three-panel layout with inline chat panel.
|
||||
static const double expanded = 1024;
|
||||
|
||||
// Panel sizing constants.
|
||||
|
||||
/// Fixed width of the left voice/control panel.
|
||||
static const double voicePanelWidth = 320;
|
||||
|
||||
/// Fixed width of the right chat panel (expanded layout only).
|
||||
static const double chatPanelWidth = 380;
|
||||
|
||||
/// Horizontal gap between panels.
|
||||
static const double panelGap = 12;
|
||||
|
||||
/// Desktop snackbar width cap (used when width ≥ [medium]).
|
||||
static const double snackBarDesktopCap = 560;
|
||||
|
||||
/// Connect form action buttons switch from row to column below this width.
|
||||
static const double connectActionsStackMaxWidth = 400;
|
||||
|
||||
/// Modal bottom sheet max height as fraction of screen height.
|
||||
static const double modalSheetHeightFraction = 0.72;
|
||||
}
|
||||
|
||||
/// Semantic layout class derived from viewport width.
|
||||
enum LayoutClass {
|
||||
/// < 600dp — single-column mobile layout.
|
||||
compact,
|
||||
|
||||
/// 600–1023dp — two-panel side-by-side layout.
|
||||
medium,
|
||||
|
||||
/// ≥ 1024dp — three-panel layout with inline chat.
|
||||
expanded,
|
||||
}
|
||||
|
||||
/// Computes the current [LayoutClass] from viewport [width].
|
||||
LayoutClass layoutClassFromWidth(double width) {
|
||||
if (width >= ChanoraBreakpoints.expanded) return LayoutClass.expanded;
|
||||
if (width >= ChanoraBreakpoints.medium) return LayoutClass.medium;
|
||||
return LayoutClass.compact;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Viewport info inherited widget for Chanora.
|
||||
//
|
||||
// Computes [LayoutClass] once per frame from the current [MediaQuery] size
|
||||
// and provides it to the entire widget subtree. Downstream widgets read
|
||||
// `ViewportInfo.of(context)` instead of calling `LayoutBuilder` or
|
||||
// `MediaQuery.sizeOf` directly for layout-class decisions.
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'breakpoints.dart';
|
||||
|
||||
/// Inherited widget that exposes the current layout class and viewport
|
||||
/// dimensions to the entire subtree.
|
||||
///
|
||||
/// Insert this once near the top of the widget tree (inside the Scaffold
|
||||
/// body or equivalent). All descendants can then read
|
||||
/// `ViewportInfo.of(context)` to determine their layout behaviour.
|
||||
class ViewportInfo extends InheritedWidget {
|
||||
/// Creates a [ViewportInfo].
|
||||
const ViewportInfo({
|
||||
super.key,
|
||||
required this.layoutClass,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
/// Current layout class derived from viewport width.
|
||||
final LayoutClass layoutClass;
|
||||
|
||||
/// Current viewport width in logical pixels.
|
||||
final double width;
|
||||
|
||||
/// Current viewport height in logical pixels.
|
||||
final double height;
|
||||
|
||||
/// Returns the nearest [ViewportInfo] in the widget tree.
|
||||
///
|
||||
/// Asserts that a [ViewportInfo] ancestor exists.
|
||||
static ViewportInfo of(BuildContext context) {
|
||||
final info = context.dependOnInheritedWidgetOfExactType<ViewportInfo>();
|
||||
assert(info != null, 'No ViewportInfo found in widget tree');
|
||||
return info!;
|
||||
}
|
||||
|
||||
/// Whether the current layout is compact (< 600dp).
|
||||
bool get isCompact => layoutClass == LayoutClass.compact;
|
||||
|
||||
/// Whether the current layout is medium (600–1023dp).
|
||||
bool get isMedium => layoutClass == LayoutClass.medium;
|
||||
|
||||
/// Whether the current layout is expanded (≥ 1024dp).
|
||||
bool get isExpanded => layoutClass == LayoutClass.expanded;
|
||||
|
||||
/// Whether the layout has room for at least two panels (medium or expanded).
|
||||
bool get isWide => !isCompact;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ViewportInfo old) => layoutClass != old.layoutClass;
|
||||
// NOTE: width/height changes within the same layout class do NOT trigger
|
||||
// notification. Dependents who genuinely need pixel-level dimensions
|
||||
// (rare — most layouts should switch on layoutClass) must use a local
|
||||
// LayoutBuilder. Notifying on every pixel would rebuild every dependent
|
||||
// on every resize frame, which is the exact pessimisation this
|
||||
// InheritedWidget exists to avoid.
|
||||
}
|
||||
@@ -46,7 +46,6 @@
|
||||
"retryAction": "Retry",
|
||||
"chatAction": "Chat",
|
||||
"chatCloseAction": "Close chat",
|
||||
"chatPanelCollapsedHint": "Tap the chat button to continue your conversation",
|
||||
"chatNewPrivateAction": "New private chat",
|
||||
"chatSearchClientsHint": "Search clients...",
|
||||
"chatDirectMessageAction": "Private message",
|
||||
@@ -242,30 +241,19 @@
|
||||
"clientInfoUnknown": "Unknown",
|
||||
"clientInfoHidden": "Hidden",
|
||||
"clientInfoNone": "None",
|
||||
"pokeSettingsAction": "Poke notifications",
|
||||
"pokeSettingsTitle": "Poke notifications",
|
||||
"pokeSettingsEnableLabel": "Notify me about pokes",
|
||||
"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": {
|
||||
"pokeSnackBarClearAction": "Clear",
|
||||
"pokeSnackBarMoreIndicator": "...",
|
||||
"pokeSnackBarIncomingNoMessage": "{sender} pokes you",
|
||||
"@pokeSnackBarIncomingNoMessage": {
|
||||
"placeholders": {
|
||||
"sender": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pokeOverflowMuteAction": "Mute",
|
||||
"pokeMutedSenderConfirmation": "Muted pokes from {sender}",
|
||||
"@pokeMutedSenderConfirmation": {
|
||||
"pokeSnackBarIncomingWithMessage": "{sender} pokes you: {message}",
|
||||
"@pokeSnackBarIncomingWithMessage": {
|
||||
"placeholders": {
|
||||
"sender": { "type": "String" }
|
||||
"sender": { "type": "String" },
|
||||
"message": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".",
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"retryAction": "重试",
|
||||
"chatAction": "聊天",
|
||||
"chatCloseAction": "关闭聊天",
|
||||
"chatPanelCollapsedHint": "点击聊天按钮以继续对话",
|
||||
"chatNewPrivateAction": "新建私聊",
|
||||
"chatSearchClientsHint": "搜索用户...",
|
||||
"chatDirectMessageAction": "私聊",
|
||||
@@ -191,30 +190,19 @@
|
||||
"clientInfoUnknown": "未知",
|
||||
"clientInfoHidden": "隐藏",
|
||||
"clientInfoNone": "无",
|
||||
"pokeSettingsAction": "戳一戳通知",
|
||||
"pokeSettingsTitle": "戳一戳通知",
|
||||
"pokeSettingsEnableLabel": "接收戳一戳通知",
|
||||
"pokeSettingsEnableDescription": "开启后,收到戳一戳时会显示本地通知。",
|
||||
"pokeSettingsMutedSendersHeader": "已静音的发送者",
|
||||
"pokeSettingsMutedSendersEmpty": "没有已静音的戳一戳发送者。",
|
||||
"pokeSettingsMutedSenderLabel": "用户 ID {senderId}",
|
||||
"@pokeSettingsMutedSenderLabel": {
|
||||
"placeholders": {
|
||||
"senderId": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pokeSettingsUnmuteSenderAction": "取消静音",
|
||||
"pokeOverflowMutePrompt": "来自 {sender} 的重复戳一戳已被抑制。要静音此发送者吗?",
|
||||
"@pokeOverflowMutePrompt": {
|
||||
"pokeSnackBarClearAction": "清除",
|
||||
"pokeSnackBarMoreIndicator": "...",
|
||||
"pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下",
|
||||
"@pokeSnackBarIncomingNoMessage": {
|
||||
"placeholders": {
|
||||
"sender": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pokeOverflowMuteAction": "静音",
|
||||
"pokeMutedSenderConfirmation": "已静音来自 {sender} 的戳一戳",
|
||||
"@pokeMutedSenderConfirmation": {
|
||||
"pokeSnackBarIncomingWithMessage": "{sender} 戳了你一下:{message}",
|
||||
"@pokeSnackBarIncomingWithMessage": {
|
||||
"placeholders": {
|
||||
"sender": { "type": "String" }
|
||||
"sender": { "type": "String" },
|
||||
"message": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。",
|
||||
|
||||
@@ -307,12 +307,6 @@ abstract class AppL10n {
|
||||
/// **'Close chat'**
|
||||
String get chatCloseAction;
|
||||
|
||||
/// No description provided for @chatPanelCollapsedHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap the chat button to continue your conversation'**
|
||||
String get chatPanelCollapsedHint;
|
||||
|
||||
/// No description provided for @chatNewPrivateAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1159,71 +1153,29 @@ abstract class AppL10n {
|
||||
/// **'None'**
|
||||
String get clientInfoNone;
|
||||
|
||||
/// No description provided for @pokeSettingsAction.
|
||||
/// No description provided for @pokeSnackBarClearAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Poke notifications'**
|
||||
String get pokeSettingsAction;
|
||||
/// **'Clear'**
|
||||
String get pokeSnackBarClearAction;
|
||||
|
||||
/// No description provided for @pokeSettingsTitle.
|
||||
/// No description provided for @pokeSnackBarMoreIndicator.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Poke notifications'**
|
||||
String get pokeSettingsTitle;
|
||||
/// **'...'**
|
||||
String get pokeSnackBarMoreIndicator;
|
||||
|
||||
/// No description provided for @pokeSettingsEnableLabel.
|
||||
/// No description provided for @pokeSnackBarIncomingNoMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Notify me about pokes'**
|
||||
String get pokeSettingsEnableLabel;
|
||||
/// **'{sender} pokes you'**
|
||||
String pokeSnackBarIncomingNoMessage(String sender);
|
||||
|
||||
/// No description provided for @pokeSettingsEnableDescription.
|
||||
/// No description provided for @pokeSnackBarIncomingWithMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show local notifications for incoming pokes when this is on.'**
|
||||
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);
|
||||
/// **'{sender} pokes you: {message}'**
|
||||
String pokeSnackBarIncomingWithMessage(String sender, String message);
|
||||
|
||||
/// No description provided for @pokeHistorySelfNoMessage.
|
||||
///
|
||||
|
||||
@@ -123,10 +123,6 @@ class AppL10nEn extends AppL10n {
|
||||
@override
|
||||
String get chatCloseAction => 'Close chat';
|
||||
|
||||
@override
|
||||
String get chatPanelCollapsedHint =>
|
||||
'Tap the chat button to continue your conversation';
|
||||
|
||||
@override
|
||||
String get chatNewPrivateAction => 'New private chat';
|
||||
|
||||
@@ -590,43 +586,19 @@ class AppL10nEn extends AppL10n {
|
||||
String get clientInfoNone => 'None';
|
||||
|
||||
@override
|
||||
String get pokeSettingsAction => 'Poke notifications';
|
||||
String get pokeSnackBarClearAction => 'Clear';
|
||||
|
||||
@override
|
||||
String get pokeSettingsTitle => 'Poke notifications';
|
||||
String get pokeSnackBarMoreIndicator => '...';
|
||||
|
||||
@override
|
||||
String get pokeSettingsEnableLabel => 'Notify me about pokes';
|
||||
|
||||
@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';
|
||||
String pokeSnackBarIncomingNoMessage(String sender) {
|
||||
return '$sender pokes you';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pokeSettingsUnmuteSenderAction => 'Unmute';
|
||||
|
||||
@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';
|
||||
String pokeSnackBarIncomingWithMessage(String sender, String message) {
|
||||
return '$sender pokes you: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -120,9 +120,6 @@ class AppL10nZh extends AppL10n {
|
||||
@override
|
||||
String get chatCloseAction => '关闭聊天';
|
||||
|
||||
@override
|
||||
String get chatPanelCollapsedHint => '点击聊天按钮以继续对话';
|
||||
|
||||
@override
|
||||
String get chatNewPrivateAction => '新建私聊';
|
||||
|
||||
@@ -577,42 +574,19 @@ class AppL10nZh extends AppL10n {
|
||||
String get clientInfoNone => '无';
|
||||
|
||||
@override
|
||||
String get pokeSettingsAction => '戳一戳通知';
|
||||
String get pokeSnackBarClearAction => '清除';
|
||||
|
||||
@override
|
||||
String get pokeSettingsTitle => '戳一戳通知';
|
||||
String get pokeSnackBarMoreIndicator => '...';
|
||||
|
||||
@override
|
||||
String get pokeSettingsEnableLabel => '接收戳一戳通知';
|
||||
|
||||
@override
|
||||
String get pokeSettingsEnableDescription => '开启后,收到戳一戳时会显示本地通知。';
|
||||
|
||||
@override
|
||||
String get pokeSettingsMutedSendersHeader => '已静音的发送者';
|
||||
|
||||
@override
|
||||
String get pokeSettingsMutedSendersEmpty => '没有已静音的戳一戳发送者。';
|
||||
|
||||
@override
|
||||
String pokeSettingsMutedSenderLabel(String senderId) {
|
||||
return '用户 ID $senderId';
|
||||
String pokeSnackBarIncomingNoMessage(String sender) {
|
||||
return '$sender 戳了你一下';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pokeSettingsUnmuteSenderAction => '取消静音';
|
||||
|
||||
@override
|
||||
String pokeOverflowMutePrompt(String sender) {
|
||||
return '来自 $sender 的重复戳一戳已被抑制。要静音此发送者吗?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pokeOverflowMuteAction => '静音';
|
||||
|
||||
@override
|
||||
String pokeMutedSenderConfirmation(String sender) {
|
||||
return '已静音来自 $sender 的戳一戳';
|
||||
String pokeSnackBarIncomingWithMessage(String sender, String message) {
|
||||
return '$sender 戳了你一下:$message';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+462
-629
File diff suppressed because it is too large
Load Diff
@@ -6,22 +6,7 @@ import '../src/rust/api.dart' as rust;
|
||||
|
||||
const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle';
|
||||
const androidAudioLifecycleChannelName = 'chanora/android_audio_lifecycle';
|
||||
const macosAudioLifecycleChannelName = 'chanora/macos_audio_lifecycle';
|
||||
|
||||
/// Parses a platform-channel route string into a [rust.BridgeAudioRoute].
|
||||
///
|
||||
/// The producer contract is:
|
||||
/// - iOS: `AppDelegate.classifyAudioRoute(_:)` emits one of
|
||||
/// `Earpiece`, `Speaker`, `WiredHeadset`, `BluetoothHfp`, `BluetoothA2dp`,
|
||||
/// `Unknown`.
|
||||
/// - Android: `AndroidAudioLifecycleController.classifyDevice` emits one of
|
||||
/// `Earpiece`, `Speaker`, `WiredHeadset`, `UsbHeadset`, `BluetoothHfp`,
|
||||
/// `BluetoothA2dp`, `Hdmi`, `Unknown`.
|
||||
///
|
||||
/// Both producers emit exact PascalCase strings. Case variants (`USB_HEADSET`,
|
||||
/// `usb_headset`, `UsbHeadphone`) are NOT handled and will fall through to
|
||||
/// `unknown`. If either platform classifier changes its string contract,
|
||||
/// update both producers and this parser together.
|
||||
rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
|
||||
switch (value) {
|
||||
case 'Earpiece':
|
||||
@@ -29,15 +14,11 @@ rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
|
||||
case 'Speaker':
|
||||
return rust.BridgeAudioRoute.speaker;
|
||||
case 'WiredHeadset':
|
||||
case 'UsbHeadset':
|
||||
return rust.BridgeAudioRoute.wiredHeadset;
|
||||
case 'BluetoothHfp':
|
||||
return rust.BridgeAudioRoute.bluetoothHfp;
|
||||
case 'BluetoothA2dp':
|
||||
return rust.BridgeAudioRoute.bluetoothA2Dp;
|
||||
case 'Hdmi':
|
||||
case 'Unknown':
|
||||
return rust.BridgeAudioRoute.unknown;
|
||||
default:
|
||||
return rust.BridgeAudioRoute.unknown;
|
||||
}
|
||||
@@ -46,7 +27,6 @@ rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
|
||||
void wireAudioLifecycle() {
|
||||
wireIosAudioLifecycle();
|
||||
wireAndroidAudioLifecycle();
|
||||
wireMacosAudioLifecycle();
|
||||
}
|
||||
|
||||
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
|
||||
@@ -125,42 +105,3 @@ void wireAndroidAudioLifecycle({
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Wire the macOS audio lifecycle MethodChannel.
|
||||
///
|
||||
/// Swift side (`MacOSAudioLifecycle`) posts `handleDefaultDeviceChange`
|
||||
/// (with `role: 'input' | 'output'`) when Core Audio HAL default-input /
|
||||
/// default-output device changes, and `handleConfigurationChange` when
|
||||
/// the VPIO AudioUnit reports a stream-format change. Closes the
|
||||
/// iOS/macOS asymmetry noted in SysRS-051.
|
||||
///
|
||||
/// Current scope: events are received and logged. The FRB
|
||||
/// `macosDefaultDeviceChanged` function that triggers a VPIO
|
||||
/// re-bind on the engine is a follow-up; until it's exposed, the
|
||||
/// macOS path mirrors the iOS `chanora/ios_audio_lifecycle` event
|
||||
/// surface but does not yet trigger an engine-side restart.
|
||||
void wireMacosAudioLifecycle({
|
||||
bool isMacos = false,
|
||||
MethodChannel channel = const MethodChannel(macosAudioLifecycleChannelName),
|
||||
}) {
|
||||
if (!isMacos && !Platform.isMacOS) return;
|
||||
channel.setMethodCallHandler((call) async {
|
||||
try {
|
||||
switch (call.method) {
|
||||
case 'handleDefaultDeviceChange':
|
||||
// TODO: call rust.macosDefaultDeviceChanged() once exposed
|
||||
// via flutter_rust_bridge; until then the event is captured
|
||||
// here for observability.
|
||||
break;
|
||||
case 'handleConfigurationChange':
|
||||
// TODO: same — currently captured, no engine action yet.
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (_) {
|
||||
// Errors from the Rust side are already logged there; do not propagate
|
||||
// exceptions to the platform framework.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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();
|
||||
@@ -63,8 +63,6 @@ const String methodTriggerLocalNetworkPrompt = 'triggerLocalNetworkPrompt';
|
||||
@visibleForTesting
|
||||
const String methodCheckLocalNetwork = 'checkLocalNetwork';
|
||||
@visibleForTesting
|
||||
const String methodCheckLocalNetworkAccess = 'checkLocalNetworkAccess';
|
||||
@visibleForTesting
|
||||
const String methodRequestNotifications = 'requestNotifications';
|
||||
@visibleForTesting
|
||||
const String methodCheckNotifications = 'checkNotifications';
|
||||
@@ -442,37 +440,6 @@ class MacOSPermissionsService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether Local Network access is currently denied for [host]:[port]
|
||||
/// by creating a short-lived NWConnection and checking
|
||||
/// `unsatisfiedReason == .localNetworkDenied`.
|
||||
///
|
||||
/// This does NOT trigger a new system prompt — it is a read-only check.
|
||||
/// Returns [MacOSLocalNetworkState.unsupported] on non-macOS platforms.
|
||||
Future<MacOSLocalNetworkState> checkLocalNetworkAccess({
|
||||
required String host,
|
||||
required int port,
|
||||
}) async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return MacOSLocalNetworkState.unsupported;
|
||||
try {
|
||||
final raw = await ch.invokeMethod<String>(
|
||||
methodCheckLocalNetworkAccess,
|
||||
<String, dynamic>{'host': host, 'port': port},
|
||||
);
|
||||
final state = _parseLocalNetworkState(raw);
|
||||
_localNetworkState.value = state;
|
||||
return state;
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'checkLocalNetworkAccess failed',
|
||||
name: 'MacOSPermissionsService',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
return _localNetworkState.value;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Outbound: Notifications ----------------------------------------------
|
||||
|
||||
Future<MacOSPermissionState> _checkNotifications() async {
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
class PokeNotificationService {
|
||||
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;
|
||||
|
||||
Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
await _notifications.initialize(
|
||||
settings: const InitializationSettings(
|
||||
android: AndroidInitializationSettings('ic_chanora_notification'),
|
||||
iOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
requestSoundPermission: false,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
defaultPresentSound: false,
|
||||
),
|
||||
macOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
requestSoundPermission: false,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
defaultPresentSound: false,
|
||||
),
|
||||
linux: LinuxInitializationSettings(
|
||||
defaultActionName: 'Open',
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
defaultSuppressSound: true,
|
||||
),
|
||||
windows: WindowsInitializationSettings(
|
||||
appName: 'Chanora',
|
||||
appUserModelId: _windowsAppUserModelId,
|
||||
guid: _windowsGuid,
|
||||
),
|
||||
),
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
playSound: false,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
silent: true,
|
||||
groupKey: _groupKey,
|
||||
category: AndroidNotificationCategory.message,
|
||||
visibility: NotificationVisibility.private,
|
||||
);
|
||||
}
|
||||
|
||||
DarwinNotificationDetails _darwinDetails(rust.BridgePokeStrength strength) {
|
||||
return DarwinNotificationDetails(
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
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(
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
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(
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
audio: WindowsNotificationAudio.silent(),
|
||||
header: _windowsHeader,
|
||||
scenario: strength == rust.BridgePokeStrength.strong
|
||||
? WindowsNotificationScenario.urgent
|
||||
: null,
|
||||
duration: strength == rust.BridgePokeStrength.strong
|
||||
? WindowsNotificationDuration.long
|
||||
: WindowsNotificationDuration.short,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
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>{},
|
||||
);
|
||||
|
||||
ValueListenable<bool> get pokesEnabled => _pokesEnabled;
|
||||
ValueListenable<Set<BigInt>> get mutedSenders => _mutedSenders;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
Future<void> setPokesEnabled(bool enabled) async {
|
||||
_pokesEnabled.value = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_enabledKey, enabled);
|
||||
}
|
||||
|
||||
Future<void> muteSender(BigInt senderId) async {
|
||||
if (_mutedSenders.value.contains(senderId)) return;
|
||||
_mutedSenders.value = {..._mutedSenders.value, senderId};
|
||||
await _saveMutedSenders();
|
||||
}
|
||||
|
||||
Future<void> unmuteSender(BigInt senderId) async {
|
||||
if (!_mutedSenders.value.contains(senderId)) return;
|
||||
_mutedSenders.value = _mutedSenders.value
|
||||
.where((mutedSender) => mutedSender != senderId)
|
||||
.toSet();
|
||||
await _saveMutedSenders();
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_pokesEnabled.dispose();
|
||||
_mutedSenders.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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 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`, `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 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 functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
|
||||
|
||||
/// Return the platform-conventional log-file path as a string, or
|
||||
@@ -263,8 +263,7 @@ Future<BridgeAudioStats> audioStats() =>
|
||||
|
||||
/// Subscribe to real-time microphone input level at ~30 Hz.
|
||||
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
|
||||
/// when the Dart subscriber cancels, the session is dropped, or
|
||||
/// the session becomes persistently unavailable.
|
||||
/// when the Dart subscriber cancels or the session is dropped.
|
||||
Stream<double> inputLevelStream() =>
|
||||
RustLib.instance.api.crateApiInputLevelStream();
|
||||
|
||||
@@ -1210,9 +1209,6 @@ sealed class BridgeEvent with _$BridgeEvent {
|
||||
|
||||
/// Target scope (server/channel/private/poke).
|
||||
required BridgeMessageTarget target,
|
||||
|
||||
/// Poke notification strength, present only for poke messages.
|
||||
BridgePokeStrength? pokeStrength,
|
||||
}) = BridgeEvent_ChatMessage;
|
||||
|
||||
/// Human-readable server activity surfaced from protocol bookkeeping events.
|
||||
@@ -1223,116 +1219,48 @@ sealed class BridgeEvent with _$BridgeEvent {
|
||||
|
||||
/// Audio route changed (speaker/earpiece/BT/wired).
|
||||
const factory BridgeEvent.audioRouteChanged({
|
||||
/// New audio output route.
|
||||
required BridgeAudioRoute route,
|
||||
}) = BridgeEvent_AudioRouteChanged;
|
||||
|
||||
/// A client moved to a different channel.
|
||||
const factory BridgeEvent.clientMoved({
|
||||
/// Unique client identifier.
|
||||
required BigInt clientId,
|
||||
|
||||
/// Destination channel.
|
||||
required BigInt newChannelId,
|
||||
}) = BridgeEvent_ClientMoved;
|
||||
|
||||
/// A new client connected.
|
||||
const factory BridgeEvent.clientJoined({
|
||||
/// Unique client identifier.
|
||||
required BigInt clientId,
|
||||
|
||||
/// Channel the client joined.
|
||||
required BigInt channelId,
|
||||
|
||||
/// Display nickname.
|
||||
required String name,
|
||||
|
||||
/// Microphone muted state.
|
||||
required bool inputMuted,
|
||||
|
||||
/// Speaker muted state.
|
||||
required bool outputMuted,
|
||||
|
||||
/// True for server query (bot) clients.
|
||||
required bool isServerQuery,
|
||||
|
||||
/// Client's talk power value.
|
||||
required int talkPower,
|
||||
|
||||
/// Whether the server granted temporary talk power.
|
||||
required bool talkPowerGranted,
|
||||
}) = BridgeEvent_ClientJoined;
|
||||
|
||||
/// A client disconnected.
|
||||
const factory BridgeEvent.clientLeft({
|
||||
/// Unique client identifier.
|
||||
required BigInt clientId,
|
||||
|
||||
/// Display nickname at time of disconnect.
|
||||
required String name,
|
||||
}) = BridgeEvent_ClientLeft;
|
||||
|
||||
/// Client properties changed.
|
||||
const factory BridgeEvent.clientUpdated({
|
||||
/// Unique client identifier.
|
||||
required BigInt clientId,
|
||||
|
||||
/// Microphone muted state.
|
||||
required bool inputMuted,
|
||||
|
||||
/// Speaker muted state.
|
||||
required bool outputMuted,
|
||||
|
||||
/// True for server query (bot) clients.
|
||||
required bool isServerQuery,
|
||||
|
||||
/// Client's talk power value.
|
||||
required int talkPower,
|
||||
|
||||
/// Whether the server granted temporary talk power.
|
||||
required bool talkPowerGranted,
|
||||
}) = BridgeEvent_ClientUpdated;
|
||||
|
||||
/// A new channel appeared.
|
||||
const factory BridgeEvent.channelAdded({
|
||||
/// Unique channel identifier.
|
||||
required BigInt id,
|
||||
|
||||
/// Parent channel ID.
|
||||
required BigInt parent,
|
||||
|
||||
/// Channel name.
|
||||
required String name,
|
||||
|
||||
/// Predecessor channel ID within the same parent (TeamSpeak
|
||||
/// linked-list ordering hint). Zero means first child.
|
||||
required PlatformInt64 order,
|
||||
|
||||
/// Whether the channel requires a password.
|
||||
required bool hasPassword,
|
||||
|
||||
/// Talk power required to speak; `None` means no restriction.
|
||||
int? neededTalkPower,
|
||||
}) = BridgeEvent_ChannelAdded;
|
||||
|
||||
/// A channel was deleted.
|
||||
const factory BridgeEvent.channelRemoved({
|
||||
/// Channel identifier.
|
||||
required BigInt id,
|
||||
}) = BridgeEvent_ChannelRemoved;
|
||||
|
||||
/// Channel properties changed.
|
||||
const factory BridgeEvent.channelRemoved({required BigInt id}) =
|
||||
BridgeEvent_ChannelRemoved;
|
||||
const factory BridgeEvent.channelUpdated({
|
||||
/// Unique channel identifier.
|
||||
required BigInt id,
|
||||
|
||||
/// Channel name.
|
||||
required String name,
|
||||
|
||||
/// Whether the channel requires a password.
|
||||
required bool hasPassword,
|
||||
|
||||
/// Talk power required to speak; `None` means no restriction.
|
||||
int? neededTalkPower,
|
||||
}) = BridgeEvent_ChannelUpdated;
|
||||
}
|
||||
@@ -1378,18 +1306,6 @@ enum BridgeNetworkState {
|
||||
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.
|
||||
class BridgePttBinding {
|
||||
/// 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, 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;
|
||||
@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;
|
||||
switch (_that) {
|
||||
case BridgeEvent_Connected() when connected != 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 interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
|
||||
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
|
||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null:
|
||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null:
|
||||
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
|
||||
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != 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, 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;
|
||||
@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;
|
||||
switch (_that) {
|
||||
case BridgeEvent_Connected():
|
||||
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 interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState():
|
||||
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage():
|
||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity():
|
||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity():
|
||||
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged():
|
||||
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved():
|
||||
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, 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;
|
||||
@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;
|
||||
switch (_that) {
|
||||
case BridgeEvent_Connected() when connected != 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 interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
|
||||
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
|
||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null:
|
||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null:
|
||||
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
|
||||
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null:
|
||||
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null:
|
||||
@@ -929,7 +929,7 @@ as PermissionStateKind,
|
||||
|
||||
|
||||
class BridgeEvent_ChatMessage extends BridgeEvent {
|
||||
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target, this.pokeStrength}): super._();
|
||||
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target}): super._();
|
||||
|
||||
|
||||
/// Client id of the sender.
|
||||
@@ -940,8 +940,6 @@ class BridgeEvent_ChatMessage extends BridgeEvent {
|
||||
final String message;
|
||||
/// Target scope (server/channel/private/poke).
|
||||
final BridgeMessageTarget target;
|
||||
/// Poke notification strength, present only for poke messages.
|
||||
final BridgePokeStrength? pokeStrength;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -953,16 +951,16 @@ $BridgeEvent_ChatMessageCopyWith<BridgeEvent_ChatMessage> get copyWith => _$Brid
|
||||
|
||||
@override
|
||||
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)&&(identical(other.pokeStrength, pokeStrength) || other.pokeStrength == pokeStrength));
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target,pokeStrength);
|
||||
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target, pokeStrength: $pokeStrength)';
|
||||
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target)';
|
||||
}
|
||||
|
||||
|
||||
@@ -973,7 +971,7 @@ abstract mixin class $BridgeEvent_ChatMessageCopyWith<$Res> implements $BridgeEv
|
||||
factory $BridgeEvent_ChatMessageCopyWith(BridgeEvent_ChatMessage value, $Res Function(BridgeEvent_ChatMessage) _then) = _$BridgeEvent_ChatMessageCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength
|
||||
BigInt senderId, String senderName, String message, BridgeMessageTarget target
|
||||
});
|
||||
|
||||
|
||||
@@ -990,14 +988,13 @@ class _$BridgeEvent_ChatMessageCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
/// 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,Object? pokeStrength = freezed,}) {
|
||||
@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,}) {
|
||||
return _then(BridgeEvent_ChatMessage(
|
||||
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 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 BridgeMessageTarget,pokeStrength: freezed == pokeStrength ? _self.pokeStrength : pokeStrength // ignore: cast_nullable_to_non_nullable
|
||||
as BridgePokeStrength?,
|
||||
as BridgeMessageTarget,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1087,7 +1084,6 @@ class BridgeEvent_AudioRouteChanged extends BridgeEvent {
|
||||
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
|
||||
|
||||
|
||||
/// New audio output route.
|
||||
final BridgeAudioRoute route;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
@@ -1154,9 +1150,7 @@ class BridgeEvent_ClientMoved extends BridgeEvent {
|
||||
const BridgeEvent_ClientMoved({required this.clientId, required this.newChannelId}): super._();
|
||||
|
||||
|
||||
/// Unique client identifier.
|
||||
final BigInt clientId;
|
||||
/// Destination channel.
|
||||
final BigInt newChannelId;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
@@ -1224,21 +1218,13 @@ 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._();
|
||||
|
||||
|
||||
/// Unique client identifier.
|
||||
final BigInt clientId;
|
||||
/// Channel the client joined.
|
||||
final BigInt channelId;
|
||||
/// Display nickname.
|
||||
final String name;
|
||||
/// Microphone muted state.
|
||||
final bool inputMuted;
|
||||
/// Speaker muted state.
|
||||
final bool outputMuted;
|
||||
/// True for server query (bot) clients.
|
||||
final bool isServerQuery;
|
||||
/// Client's talk power value.
|
||||
final int talkPower;
|
||||
/// Whether the server granted temporary talk power.
|
||||
final bool talkPowerGranted;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
@@ -1312,9 +1298,7 @@ class BridgeEvent_ClientLeft extends BridgeEvent {
|
||||
const BridgeEvent_ClientLeft({required this.clientId, required this.name}): super._();
|
||||
|
||||
|
||||
/// Unique client identifier.
|
||||
final BigInt clientId;
|
||||
/// Display nickname at time of disconnect.
|
||||
final String name;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
@@ -1382,17 +1366,11 @@ 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._();
|
||||
|
||||
|
||||
/// Unique client identifier.
|
||||
final BigInt clientId;
|
||||
/// Microphone muted state.
|
||||
final bool inputMuted;
|
||||
/// Speaker muted state.
|
||||
final bool outputMuted;
|
||||
/// True for server query (bot) clients.
|
||||
final bool isServerQuery;
|
||||
/// Client's talk power value.
|
||||
final int talkPower;
|
||||
/// Whether the server granted temporary talk power.
|
||||
final bool talkPowerGranted;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
@@ -1464,18 +1442,11 @@ 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._();
|
||||
|
||||
|
||||
/// Unique channel identifier.
|
||||
final BigInt id;
|
||||
/// Parent channel ID.
|
||||
final BigInt parent;
|
||||
/// Channel name.
|
||||
final String name;
|
||||
/// Predecessor channel ID within the same parent (TeamSpeak
|
||||
/// linked-list ordering hint). Zero means first child.
|
||||
final PlatformInt64 order;
|
||||
/// Whether the channel requires a password.
|
||||
final bool hasPassword;
|
||||
/// Talk power required to speak; `None` means no restriction.
|
||||
final int? neededTalkPower;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
@@ -1547,7 +1518,6 @@ class BridgeEvent_ChannelRemoved extends BridgeEvent {
|
||||
const BridgeEvent_ChannelRemoved({required this.id}): super._();
|
||||
|
||||
|
||||
/// Channel identifier.
|
||||
final BigInt id;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
@@ -1614,13 +1584,9 @@ class BridgeEvent_ChannelUpdated extends BridgeEvent {
|
||||
const BridgeEvent_ChannelUpdated({required this.id, required this.name, required this.hasPassword, this.neededTalkPower}): super._();
|
||||
|
||||
|
||||
/// Unique channel identifier.
|
||||
final BigInt id;
|
||||
/// Channel name.
|
||||
final String name;
|
||||
/// Whether the channel requires a password.
|
||||
final bool hasPassword;
|
||||
/// Talk power required to speak; `None` means no restriction.
|
||||
final int? neededTalkPower;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
|
||||
@@ -1683,12 +1683,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
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
|
||||
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
||||
dynamic raw,
|
||||
@@ -2013,7 +2007,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
senderName: dco_decode_String(raw[2]),
|
||||
message: dco_decode_String(raw[3]),
|
||||
target: dco_decode_box_autoadd_bridge_message_target(raw[4]),
|
||||
pokeStrength: dco_decode_opt_box_autoadd_bridge_poke_strength(raw[5]),
|
||||
);
|
||||
case 11:
|
||||
return BridgeEvent_ServerActivity(message: dco_decode_String(raw[1]));
|
||||
@@ -2105,12 +2098,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
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
|
||||
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
@@ -2247,16 +2234,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
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
|
||||
BridgeVoiceJoinErrorCode?
|
||||
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw) {
|
||||
@@ -2381,14 +2358,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
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
|
||||
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
||||
SseDeserializer deserializer,
|
||||
@@ -2840,15 +2809,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
var var_target = sse_decode_box_autoadd_bridge_message_target(
|
||||
deserializer,
|
||||
);
|
||||
var var_pokeStrength = sse_decode_opt_box_autoadd_bridge_poke_strength(
|
||||
deserializer,
|
||||
);
|
||||
return BridgeEvent_ChatMessage(
|
||||
senderId: var_senderId,
|
||||
senderName: var_senderName,
|
||||
message: var_message,
|
||||
target: var_target,
|
||||
pokeStrength: var_pokeStrength,
|
||||
);
|
||||
case 11:
|
||||
var var_message = sse_decode_String(deserializer);
|
||||
@@ -2976,15 +2941,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
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
|
||||
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -3176,19 +3132,6 @@ 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
|
||||
BridgeVoiceJoinErrorCode?
|
||||
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||
@@ -3363,15 +3306,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
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
|
||||
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
||||
BridgeVoiceJoinErrorCode self,
|
||||
@@ -3710,17 +3644,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
senderName: final senderName,
|
||||
message: final message,
|
||||
target: final target,
|
||||
pokeStrength: final pokeStrength,
|
||||
):
|
||||
sse_encode_i_32(10, serializer);
|
||||
sse_encode_u_64(senderId, serializer);
|
||||
sse_encode_String(senderName, serializer);
|
||||
sse_encode_String(message, 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):
|
||||
sse_encode_i_32(11, serializer);
|
||||
sse_encode_String(message, serializer);
|
||||
@@ -3842,15 +3771,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
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
|
||||
void sse_encode_bridge_ptt_binding(
|
||||
BridgePttBinding self,
|
||||
@@ -4027,19 +3947,6 @@ 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
|
||||
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||
BridgeVoiceJoinErrorCode? self,
|
||||
|
||||
@@ -46,9 +46,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
||||
dynamic raw,
|
||||
@@ -123,9 +120,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
|
||||
|
||||
@@ -180,11 +174,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
String? dco_decode_opt_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode?
|
||||
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
|
||||
@@ -251,11 +240,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
||||
SseDeserializer deserializer,
|
||||
@@ -344,11 +328,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgePokeStrength sse_decode_bridge_poke_strength(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
|
||||
|
||||
@@ -421,11 +400,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
String? sse_decode_opt_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode?
|
||||
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||
@@ -503,12 +477,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_bridge_poke_strength(
|
||||
BridgePokeStrength self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
||||
BridgeVoiceJoinErrorCode self,
|
||||
@@ -620,12 +588,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_poke_strength(
|
||||
BridgePokeStrength self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_ptt_binding(
|
||||
BridgePttBinding self,
|
||||
@@ -719,12 +681,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_bridge_poke_strength(
|
||||
BridgePokeStrength? self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||
BridgeVoiceJoinErrorCode? self,
|
||||
|
||||
@@ -48,9 +48,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
||||
dynamic raw,
|
||||
@@ -125,9 +122,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
|
||||
|
||||
@@ -182,11 +176,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
String? dco_decode_opt_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode?
|
||||
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
|
||||
@@ -253,11 +242,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
||||
SseDeserializer deserializer,
|
||||
@@ -346,11 +330,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgePokeStrength sse_decode_bridge_poke_strength(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
|
||||
|
||||
@@ -423,11 +402,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
String? sse_decode_opt_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode?
|
||||
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||
@@ -505,12 +479,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_bridge_poke_strength(
|
||||
BridgePokeStrength self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
||||
BridgeVoiceJoinErrorCode self,
|
||||
@@ -622,12 +590,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_poke_strength(
|
||||
BridgePokeStrength self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_ptt_binding(
|
||||
BridgePttBinding self,
|
||||
@@ -721,12 +683,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_bridge_poke_strength(
|
||||
BridgePokeStrength? self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||
BridgeVoiceJoinErrorCode? self,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/breakpoints.dart';
|
||||
|
||||
/// Semantic tones for lightweight, Material 3 SnackBars.
|
||||
enum AppSnackBarVariant { neutral, success, warning, error }
|
||||
|
||||
@@ -9,6 +7,7 @@ enum AppSnackBarVariant { neutral, success, warning, error }
|
||||
class AppSnackBar {
|
||||
const AppSnackBar._();
|
||||
|
||||
static const double _desktopMaxWidth = 560;
|
||||
static const double _radius = 16;
|
||||
static const double _elevation = 3;
|
||||
|
||||
@@ -40,9 +39,9 @@ class AppSnackBar {
|
||||
}) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final viewWidth = MediaQuery.sizeOf(context).width;
|
||||
final useDesktopCap = viewWidth >= ChanoraBreakpoints.medium;
|
||||
final useDesktopCap = viewWidth >= 600;
|
||||
final snackBarWidth = useDesktopCap && margin == null
|
||||
? ChanoraBreakpoints.snackBarDesktopCap
|
||||
? _desktopMaxWidth
|
||||
: null;
|
||||
final effectiveMargin = useDesktopCap && margin != null
|
||||
? _desktopCappedMargin(context, margin)
|
||||
@@ -77,11 +76,7 @@ class AppSnackBar {
|
||||
final viewWidth = MediaQuery.sizeOf(context).width;
|
||||
final resolved = margin.resolve(Directionality.of(context));
|
||||
final extraHorizontal =
|
||||
(viewWidth - ChanoraBreakpoints.snackBarDesktopCap).clamp(
|
||||
0.0,
|
||||
viewWidth,
|
||||
) /
|
||||
2;
|
||||
(viewWidth - _desktopMaxWidth).clamp(0.0, viewWidth) / 2;
|
||||
return EdgeInsets.fromLTRB(
|
||||
resolved.left + extraHorizontal,
|
||||
resolved.top,
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/breakpoints.dart';
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../services/snapshot_state_mapper.dart';
|
||||
import '../services/ts3_server_link.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
import 'chat_views.dart';
|
||||
|
||||
/// Fixed-width inline chat panel for expanded desktop layouts.
|
||||
class ChatPanel extends StatelessWidget {
|
||||
/// Construct an inline chat panel.
|
||||
const ChatPanel({
|
||||
super.key,
|
||||
required this.messages,
|
||||
required this.snapshot,
|
||||
required this.target,
|
||||
required this.clientName,
|
||||
required this.onClose,
|
||||
this.restoredDraft,
|
||||
this.onDraftChanged,
|
||||
this.onTs3ServerLink,
|
||||
});
|
||||
|
||||
/// Backing chat messages shared with the chat route.
|
||||
final List<ChatEntry> messages;
|
||||
|
||||
/// Latest TeamSpeak snapshot.
|
||||
final rust.BridgeSnapshot snapshot;
|
||||
|
||||
/// Chat target shown in the panel.
|
||||
final rust.BridgeMessageTarget target;
|
||||
|
||||
/// Client display name for direct-message and poke targets.
|
||||
final String clientName;
|
||||
|
||||
/// Handle TeamSpeak server links embedded in chat messages.
|
||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||
|
||||
/// Called when the user closes the inline panel.
|
||||
final VoidCallback onClose;
|
||||
|
||||
/// External draft text to restore in the chat detail view.
|
||||
final String? restoredDraft;
|
||||
|
||||
/// Called when the draft text changes.
|
||||
final ValueChanged<String>? onDraftChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentChannelId = ownClientSnapshotState(snapshot)?.channelId;
|
||||
final channelName = snapshotChannelName(snapshot, currentChannelId);
|
||||
final l10n = AppL10n.of(context);
|
||||
|
||||
return SizedBox(
|
||||
width: ChanoraBreakpoints.chatPanelWidth,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: BorderDirectional(
|
||||
start: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ChatDetailView(
|
||||
messages: messages,
|
||||
snapshot: snapshot,
|
||||
target: target,
|
||||
clientName: clientName,
|
||||
currentChannelId: currentChannelId,
|
||||
channelName: channelName,
|
||||
onTs3ServerLink: onTs3ServerLink,
|
||||
restoredDraft: restoredDraft,
|
||||
onDraftChanged: onDraftChanged,
|
||||
messageMaxWidth: 500,
|
||||
headerTrailing: IconButton(
|
||||
tooltip: l10n.chatCloseAction,
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: onClose,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/breakpoints.dart';
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../services/channel_spacer.dart';
|
||||
import '../services/link_trust_service.dart';
|
||||
@@ -14,12 +13,7 @@ import 'bbcode_text.dart';
|
||||
const double _chatSidebarTileExtent = 92;
|
||||
const double _chatSidebarCompactTileExtent = 76;
|
||||
const double _chatSidebarCompactHeight = 84;
|
||||
|
||||
typedef ChatMessageSender =
|
||||
Future<void> Function({
|
||||
required String message,
|
||||
required rust.BridgeMessageTarget target,
|
||||
});
|
||||
const double _chatMobileBreakpoint = 600;
|
||||
|
||||
/// One chat/activity message shown in the chat hub.
|
||||
class ChatEntry {
|
||||
@@ -503,7 +497,7 @@ String chatInputPlaceholder(
|
||||
case rust.BridgeMessageTarget_Client():
|
||||
return 'Message $clientName...';
|
||||
case rust.BridgeMessageTarget_Poke():
|
||||
return 'Poke message optional...';
|
||||
return 'Poke message...';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,16 +513,6 @@ 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(
|
||||
rust.BridgeMessageTarget target,
|
||||
BigInt? currentChannelId,
|
||||
@@ -632,7 +616,7 @@ class _ChatPageState extends State<ChatPage> {
|
||||
final currentChannelId = _currentChannelId;
|
||||
final channelName = snapshotChannelName(snapshot, currentChannelId);
|
||||
final l10n = AppL10n.of(context);
|
||||
final detail = ChatDetailView(
|
||||
final detail = _ChatDetailView(
|
||||
target: _selectedTarget,
|
||||
clientName: _selectedClientName,
|
||||
snapshot: snapshot,
|
||||
@@ -657,7 +641,7 @@ class _ChatPageState extends State<ChatPage> {
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final sidebar = _ChatSidebar(
|
||||
compact: constraints.maxWidth < ChanoraBreakpoints.medium,
|
||||
compact: constraints.maxWidth < _chatMobileBreakpoint,
|
||||
selectedTarget: _selectedTarget,
|
||||
privateChats: _privateChats,
|
||||
onSelect: _selectTarget,
|
||||
@@ -666,7 +650,7 @@ class _ChatPageState extends State<ChatPage> {
|
||||
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
|
||||
}),
|
||||
);
|
||||
if (constraints.maxWidth < ChanoraBreakpoints.medium) {
|
||||
if (constraints.maxWidth < _chatMobileBreakpoint) {
|
||||
return Column(
|
||||
children: [
|
||||
sidebar,
|
||||
@@ -1066,11 +1050,8 @@ class _ChannelGroup extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detail view for a single chat target, including message history and input.
|
||||
class ChatDetailView extends StatefulWidget {
|
||||
/// Construct a chat detail view.
|
||||
const ChatDetailView({
|
||||
super.key,
|
||||
class _ChatDetailView extends StatefulWidget {
|
||||
const _ChatDetailView({
|
||||
required this.target,
|
||||
required this.clientName,
|
||||
required this.snapshot,
|
||||
@@ -1078,54 +1059,21 @@ class ChatDetailView extends StatefulWidget {
|
||||
required this.currentChannelId,
|
||||
required this.channelName,
|
||||
this.onTs3ServerLink,
|
||||
this.headerTrailing,
|
||||
this.messageMaxWidth,
|
||||
this.restoredDraft,
|
||||
this.onDraftChanged,
|
||||
this.sendChatMessage,
|
||||
});
|
||||
|
||||
/// Chat target displayed by this detail view.
|
||||
final rust.BridgeMessageTarget target;
|
||||
|
||||
/// Client display name for direct-message and poke targets.
|
||||
final String clientName;
|
||||
|
||||
/// Latest TeamSpeak snapshot.
|
||||
final rust.BridgeSnapshot snapshot;
|
||||
|
||||
/// Backing message list. Self-sent messages are appended here.
|
||||
final List<ChatEntry> messages;
|
||||
|
||||
/// Current voice channel id for channel-chat send gating.
|
||||
final BigInt? currentChannelId;
|
||||
|
||||
/// Current voice channel name for labels and placeholders.
|
||||
final String channelName;
|
||||
|
||||
/// Handle TeamSpeak server links embedded in chat messages.
|
||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||
|
||||
/// Optional widget shown at the trailing edge of the header.
|
||||
final Widget? headerTrailing;
|
||||
|
||||
/// Optional max width for message content.
|
||||
final double? messageMaxWidth;
|
||||
|
||||
/// External draft text to restore when the widget initializes or the target changes.
|
||||
final String? restoredDraft;
|
||||
|
||||
/// Called with the current draft text whenever the target changes or the widget is about to be replaced.
|
||||
final ValueChanged<String>? onDraftChanged;
|
||||
|
||||
/// Sends a chat message. Defaults to the Rust bridge send path.
|
||||
final ChatMessageSender? sendChatMessage;
|
||||
|
||||
@override
|
||||
State<ChatDetailView> createState() => _ChatDetailViewState();
|
||||
State<_ChatDetailView> createState() => _ChatDetailViewState();
|
||||
}
|
||||
|
||||
class _ChatDetailViewState extends State<ChatDetailView> {
|
||||
class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
final _textCtl = TextEditingController();
|
||||
final _scrollCtl = ScrollController();
|
||||
int _lastRenderedMessageCount = -1;
|
||||
@@ -1152,35 +1100,8 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
||||
clientName: widget.clientName,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.restoredDraft != null && widget.restoredDraft!.isNotEmpty) {
|
||||
_textCtl.text = widget.restoredDraft!;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ChatDetailView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.target != widget.target) {
|
||||
// Propagate the OUTGOING draft unconditionally, including empty
|
||||
// text. The empty case is load-bearing: if the user typed text,
|
||||
// saved it, restored it, then deleted everything, the parent
|
||||
// map must learn the draft is now empty — otherwise the stale
|
||||
// entry resurrects on the next target swap.
|
||||
oldWidget.onDraftChanged?.call(_textCtl.text);
|
||||
_textCtl.text = widget.restoredDraft ?? '';
|
||||
_lastRenderedTarget = null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Same unconditional flush on tear-down. The `isNotEmpty` guard
|
||||
// here would silently drop "user cleared the field then closed
|
||||
// the panel" into the same stale-entry bug class as didUpdateWidget.
|
||||
widget.onDraftChanged?.call(_textCtl.text);
|
||||
_textCtl.dispose();
|
||||
_scrollCtl.dispose();
|
||||
super.dispose();
|
||||
@@ -1188,12 +1109,9 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
||||
|
||||
void _send() {
|
||||
final text = _textCtl.text.trim();
|
||||
if (!canSendChatMessage(widget.target, widget.currentChannelId, text)) {
|
||||
return;
|
||||
}
|
||||
if (text.isEmpty || !_canSend) return;
|
||||
_textCtl.clear();
|
||||
final sendChatMessage = widget.sendChatMessage ?? rust.sendChatMessage;
|
||||
unawaited(sendChatMessage(message: text, target: widget.target));
|
||||
unawaited(rust.sendChatMessage(message: text, target: widget.target));
|
||||
final ownId = widget.snapshot.ownClientId;
|
||||
setState(() {
|
||||
widget.messages.add(
|
||||
@@ -1246,9 +1164,6 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
||||
channelName: widget.channelName,
|
||||
clientName: widget.clientName,
|
||||
);
|
||||
final sendTooltip = widget.target is rust.BridgeMessageTarget_Poke
|
||||
? 'Poke'
|
||||
: 'Send';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -1261,12 +1176,7 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
||||
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(_title, style: theme.textTheme.titleMedium)),
|
||||
if (widget.headerTrailing != null) widget.headerTrailing!,
|
||||
],
|
||||
),
|
||||
child: Text(_title, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
Expanded(
|
||||
child: msgs.isEmpty
|
||||
@@ -1306,16 +1216,9 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
||||
controller: _scrollCtl,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: msgs.length,
|
||||
itemBuilder: (_, i) => Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: widget.messageMaxWidth ?? double.infinity,
|
||||
),
|
||||
child: _MessageBubble(
|
||||
entry: msgs[i],
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
),
|
||||
),
|
||||
itemBuilder: (_, i) => _MessageBubble(
|
||||
entry: msgs[i],
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1358,7 +1261,7 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _send,
|
||||
tooltip: sendTooltip,
|
||||
tooltip: 'Send',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../design/breakpoints.dart';
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
@@ -115,6 +114,7 @@ class _ConnectFormState extends State<ConnectForm> {
|
||||
const SizedBox(height: 16),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const stackedActionsMaxWidth = 400.0;
|
||||
final connectButton = FilledButton.icon(
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text(l10n.connectAction),
|
||||
@@ -125,8 +125,7 @@ class _ConnectFormState extends State<ConnectForm> {
|
||||
label: Text(l10n.bookmarkAddAction),
|
||||
onPressed: widget.onAddBookmark,
|
||||
);
|
||||
if (constraints.maxWidth <=
|
||||
ChanoraBreakpoints.connectActionsStackMaxWidth) {
|
||||
if (constraints.maxWidth <= stackedActionsMaxWidth) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../services/poke_preferences_service.dart';
|
||||
import 'voice_settings_controls.dart';
|
||||
|
||||
class PokeNotificationSettingsDialog extends StatelessWidget {
|
||||
const PokeNotificationSettingsDialog({super.key, required this.preferences});
|
||||
|
||||
final PokePreferencesService preferences;
|
||||
|
||||
@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) => preferences.setPokesEnabled(value),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,14 +23,12 @@ class SnapshotView extends StatefulWidget {
|
||||
required this.localOutputMuted,
|
||||
required this.hasJoinPending,
|
||||
required this.canJoinVoiceChannel,
|
||||
required this.unreadChannelIds,
|
||||
required this.onJoinChannel,
|
||||
required this.onJoinChannelWithPassword,
|
||||
this.enableClientLongPressMenu = false,
|
||||
this.onOpenClientInfo,
|
||||
this.onOpenClientChat,
|
||||
this.onOpenClientPoke,
|
||||
this.onOpenChannelChat,
|
||||
this.onTs3ServerLink,
|
||||
});
|
||||
|
||||
@@ -58,9 +56,6 @@ class SnapshotView extends StatefulWidget {
|
||||
/// True when the local client may join voice channels.
|
||||
final bool canJoinVoiceChannel;
|
||||
|
||||
/// Set of channel IDs that have unread chat messages.
|
||||
final Set<BigInt> unreadChannelIds;
|
||||
|
||||
/// Join an unlocked channel.
|
||||
final ValueChanged<rust.BridgeChannel> onJoinChannel;
|
||||
|
||||
@@ -79,9 +74,6 @@ class SnapshotView extends StatefulWidget {
|
||||
/// Open a poke composer for a non-self client.
|
||||
final ValueChanged<rust.BridgeClient>? onOpenClientPoke;
|
||||
|
||||
/// Open chat for a channel.
|
||||
final ValueChanged<rust.BridgeChannel>? onOpenChannelChat;
|
||||
|
||||
/// Handle TeamSpeak server links embedded in server-provided text.
|
||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||
|
||||
@@ -248,64 +240,47 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
);
|
||||
}
|
||||
|
||||
return _ChannelContextMenu(
|
||||
channel: channel,
|
||||
onChat: widget.onOpenChannelChat != null
|
||||
? () => widget.onOpenChannelChat!(channel)
|
||||
: null,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 40),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: channelIndent),
|
||||
_expandButton(
|
||||
theme,
|
||||
hasVisibleChildren: hasVisibleChildren,
|
||||
expanded: expanded,
|
||||
onPressed: onToggleExpanded,
|
||||
),
|
||||
SizedBox(
|
||||
width: _channelIconColumnWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Icon(
|
||||
Icons.tag,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _channelTextGap),
|
||||
Expanded(
|
||||
child: Text(
|
||||
channel.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (widget.unreadChannelIds.contains(channel.id)) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (channel.hasPassword) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
],
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 40),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: channelIndent),
|
||||
_expandButton(
|
||||
theme,
|
||||
hasVisibleChildren: hasVisibleChildren,
|
||||
expanded: expanded,
|
||||
onPressed: onToggleExpanded,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: _channelIconColumnWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Icon(
|
||||
Icons.tag,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _channelTextGap),
|
||||
Expanded(
|
||||
child: Text(
|
||||
channel.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (channel.hasPassword) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1107,60 +1082,3 @@ class _ClientVolumeSheetState extends State<_ClientVolumeSheet> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Context menu for channel tiles. Shows a "Chat" option on right-click or
|
||||
/// long-press. Primary tap passes through to the child for voice join.
|
||||
class _ChannelContextMenu extends StatelessWidget {
|
||||
const _ChannelContextMenu({
|
||||
required this.channel,
|
||||
this.onChat,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final rust.BridgeChannel channel;
|
||||
final VoidCallback? onChat;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (onChat == null) return child;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onSecondaryTapDown: (details) =>
|
||||
_show(context, details.globalPosition),
|
||||
onLongPressStart: (details) =>
|
||||
_show(context, details.globalPosition),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
void _show(BuildContext context, Offset globalPosition) {
|
||||
final overlay =
|
||||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||
final position = RelativeRect.fromLTRB(
|
||||
globalPosition.dx,
|
||||
globalPosition.dy,
|
||||
overlay.size.width - globalPosition.dx,
|
||||
overlay.size.height - globalPosition.dy,
|
||||
);
|
||||
showMenu<String>(
|
||||
context: context,
|
||||
position: position,
|
||||
items: [
|
||||
PopupMenuItem(
|
||||
value: 'chat',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.chat_bubble_outline, size: 18),
|
||||
const SizedBox(width: 12),
|
||||
Text(AppL10n.of(context).chatAction),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
).then((value) {
|
||||
if (value == 'chat') onChat?.call();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,15 +318,6 @@ class _VoicePttButtonState extends State<VoicePttButton> {
|
||||
playVoicePttHaptic(held);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_pressed) {
|
||||
_pressed = false;
|
||||
widget.onHeldChanged(false);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
@@ -1,6 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
|
||||
// Mirror Flutter-Release.xcconfig (see explanation there).
|
||||
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
|
||||
STRIP_STYLE = non-global
|
||||
|
||||
@@ -1,11 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
|
||||
// macOS Release defaults to DEAD_CODE_STRIPPING = YES. See
|
||||
// ios/Flutter/Release.xcconfig for the full rationale; -u is the
|
||||
// load-bearing flag, -exported_symbol re-exports for dlsym, both
|
||||
// are intentionally present per @_cdecl symbol.
|
||||
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
|
||||
|
||||
// See ios/Flutter/Release.xcconfig for the STRIP_STYLE rationale.
|
||||
STRIP_STYLE = non-global
|
||||
|
||||
@@ -13,7 +13,7 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
chanora_bridge: 9d1469952801a1caa3bb56d5d3bce91df8dca4ad
|
||||
chanora_bridge: 4105993843b5421ee4ce72220a74c63f6fd99103
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
|
||||
PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb
|
||||
|
||||
@@ -248,7 +248,6 @@
|
||||
4287874B577AE59BBE39386D /* [CP] Check Pods Manifest.lock */,
|
||||
33CC10E92044A3C60003C045 /* Sources */,
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||
CA110002000000000000A200 /* Verify Silero Exports */,
|
||||
33CC10EB2044A3C60003C045 /* Resources */,
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||
@@ -442,21 +441,6 @@
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
CA110002000000000000A200 /* Verify Silero Exports */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Verify Silero Exports";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${SRCROOT}/../scripts/verify_silero_exports.sh\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
|
||||
@@ -7,10 +7,6 @@ class AppDelegate: FlutterAppDelegate {
|
||||
override func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
super.applicationDidFinishLaunching(notification)
|
||||
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
ChanoraSileroSelfTest.run()
|
||||
}
|
||||
|
||||
// Ask for microphone access on launch rather than on first
|
||||
// voice-channel join. Matches user expectations for a voice
|
||||
// chat client and saves the user from a surprising prompt
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Flutter default: app-sandbox + JIT for the Dart VM. network.server is
|
||||
required for the `flutter run` hot-reload listener AND, on macOS, for
|
||||
every UDP bind() the app does (tsclientlib binds 0.0.0.0:0 for
|
||||
outbound TS3 traffic and the sandbox treats that as a server op). -->
|
||||
<!-- Flutter default: app-sandbox + JIT for the Dart VM + server
|
||||
listener for `flutter run` hot-reload. -->
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
@@ -42,8 +40,6 @@
|
||||
<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>
|
||||
<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>
|
||||
<array>
|
||||
<string>_ts3._tcp</string>
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import Cocoa
|
||||
import CoreAudio
|
||||
import FlutterMacOS
|
||||
import os.log
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MacOSAudioLifecycle
|
||||
//
|
||||
// Native-side MethodChannel handler for macOS audio lifecycle events.
|
||||
// Closes the iOS / macOS asymmetry that the iOS AppDelegate handles via
|
||||
// AVAudioSession (no AVAudioSession equivalent on macOS). The macOS
|
||||
// equivalents of the iOS lifecycle events are:
|
||||
//
|
||||
// * Default audio device change — Core Audio HAL default-input /
|
||||
// default-output device property listeners (mirrors iOS route
|
||||
// change). Posted as `handleDefaultDeviceChange` with payload
|
||||
// `{role: 'input' | 'output'}`.
|
||||
//
|
||||
// * VPIO AudioUnit configuration change — observed via the VPIO
|
||||
// unit's `kAudioUnitProperty_StreamFormat` property listener.
|
||||
// Posted as `handleConfigurationChange` with no payload.
|
||||
//
|
||||
// Channel name: `chanora/macos_audio_lifecycle`. The Dart side wires the
|
||||
// matching `chanora/ios_audio_lifecycle`-shaped event surface in
|
||||
// `audio_lifecycle_service.dart` and currently logs the events; the
|
||||
// engine-restart call (FRB `macos_default_device_changed`) is a
|
||||
// follow-up.
|
||||
//
|
||||
// Trace: SysRS-051 (macOS audio-lifecycle asymmetry), SysRS-311
|
||||
// (the deferred macOS audio-lifecycle platform-adapter allocation).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private let kLogTag = "chanora_flutter.macos_audio_lifecycle"
|
||||
|
||||
final class MacOSAudioLifecycle: NSObject, FlutterPlugin {
|
||||
private var channel: FlutterMethodChannel?
|
||||
private var listenerBlocks: [AudioObjectID: AudioObjectPropertyListenerBlock] = [:]
|
||||
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "chanora/macos_audio_lifecycle",
|
||||
binaryMessenger: registrar.messenger
|
||||
)
|
||||
let instance = MacOSAudioLifecycle()
|
||||
instance.channel = channel
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
instance.startObserving()
|
||||
}
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
// macOS audio lifecycle is a one-way push: Swift → Dart.
|
||||
// The Dart side never invokes methods on this channel.
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
|
||||
// MARK: - Core Audio HAL listeners
|
||||
|
||||
private func startObserving() {
|
||||
registerDefaultDeviceListener(role: "output")
|
||||
registerDefaultDeviceListener(role: "input")
|
||||
}
|
||||
|
||||
private func registerDefaultDeviceListener(role: String) {
|
||||
let selector: AudioObjectPropertySelector = (role == "input")
|
||||
? kAudioHardwarePropertyDefaultInputDevice
|
||||
: kAudioHardwarePropertyDefaultOutputDevice
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: selector,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
let objectID: AudioObjectID = AudioObjectID(kAudioObjectSystemObject)
|
||||
let channel = self.channel
|
||||
let block: AudioObjectPropertyListenerBlock = { _, _ in
|
||||
os_log("default %{public}@ device changed", log: OSLog(subsystem: kLogTag, category: "lifecycle"), type: .info, role)
|
||||
channel?.invokeMethod("handleDefaultDeviceChange", arguments: ["role": role])
|
||||
}
|
||||
let status = AudioObjectAddPropertyListener(objectID, &address, block, nil)
|
||||
if status == noErr {
|
||||
listenerBlocks[objectID + UInt32(role.hashValue & 0xFFFF)] = block
|
||||
os_log("registered default %{public}@ device listener", log: OSLog(subsystem: kLogTag, category: "lifecycle"), type: .info, role)
|
||||
} else {
|
||||
os_log("failed to register default %{public}@ device listener (OSStatus %{public}d)",
|
||||
log: OSLog(subsystem: kLogTag, category: "lifecycle"),
|
||||
type: .error, role, Int(status))
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Best-effort cleanup; AudioObjectRemovePropertyListener only
|
||||
// matters if the system still holds the block.
|
||||
for (id, block) in listenerBlocks {
|
||||
for selector in [
|
||||
kAudioHardwarePropertyDefaultInputDevice,
|
||||
kAudioHardwarePropertyDefaultOutputDevice,
|
||||
] {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: selector,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
_ = AudioObjectRemovePropertyListener(id, &address, block, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,18 +63,6 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
||||
case "triggerLocalNetworkPrompt":
|
||||
triggerLocalNetworkPrompt(result: result)
|
||||
|
||||
case "checkLocalNetworkAccess":
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let host = args["host"] as? String,
|
||||
let port = args["port"] as? Int else {
|
||||
result(FlutterError(
|
||||
code: "INVALID_ARGS",
|
||||
message: "checkLocalNetworkAccess requires host (String) and port (Int)",
|
||||
details: nil))
|
||||
return
|
||||
}
|
||||
checkLocalNetworkAccess(host: host, port: port, result: result)
|
||||
|
||||
// -- Notifications ------------------------------------------------------
|
||||
case "checkNotifications":
|
||||
checkNotifications(result: result)
|
||||
@@ -211,10 +199,10 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
||||
case .failed(let error):
|
||||
if !resolved {
|
||||
resolved = true
|
||||
// Check for DNS policy-denied error (kDNSServiceErr_PolicyDenied = -65570).
|
||||
// This is the canonical signal that the user denied the Local Network prompt.
|
||||
if case .dns(let dnsError) = error,
|
||||
dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) {
|
||||
let code = error.errorCode
|
||||
// POSIX permission-denied or network-down signals that
|
||||
// the user denied the Local Network prompt.
|
||||
if code == ENETDOWN || code == EACCES || code == EPERM {
|
||||
result("Denied")
|
||||
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
|
||||
"state": "Denied",
|
||||
@@ -226,23 +214,11 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
||||
}
|
||||
}
|
||||
browser.cancel()
|
||||
case .waiting(let error):
|
||||
// The browser is waiting for network. If the specific DNS error
|
||||
// is kDNSServiceErr_PolicyDenied, the user explicitly denied
|
||||
// the Local Network prompt — report immediately.
|
||||
if case .dns(let dnsError) = error,
|
||||
dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) {
|
||||
if !resolved {
|
||||
resolved = true
|
||||
result("Denied")
|
||||
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
|
||||
"state": "Denied",
|
||||
])
|
||||
}
|
||||
browser.cancel()
|
||||
}
|
||||
// Otherwise the system dialog may be showing — wait for
|
||||
// .ready, .failed, or the timeout.
|
||||
case .waiting:
|
||||
// The browser is waiting for network — this is normal and
|
||||
// may mean the permission dialog is showing. Don't resolve
|
||||
// yet; wait for .ready or .failed or the timeout.
|
||||
break
|
||||
case .setup, .cancelled:
|
||||
break
|
||||
@unknown default:
|
||||
@@ -271,77 +247,6 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether Local Network access is currently denied for a specific
|
||||
/// host:port by creating a short-lived NWConnection and checking
|
||||
/// `unsatisfiedReason == .localNetworkDenied`. This does NOT trigger a
|
||||
/// new system prompt — it is a read-only check.
|
||||
private func checkLocalNetworkAccess(
|
||||
host: String, port: Int, result: @escaping FlutterResult
|
||||
) {
|
||||
if #available(macOS 15.0, *) {
|
||||
checkLocalNetworkAccessImpl(host: host, port: port, result: result)
|
||||
} else {
|
||||
result("Unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 15.0, *)
|
||||
private func checkLocalNetworkAccessImpl(
|
||||
host: String, port: Int, result: @escaping FlutterResult
|
||||
) {
|
||||
guard let endpointPort = NWEndpoint.Port(rawValue: UInt16(port)) else {
|
||||
result("NotDetermined")
|
||||
return
|
||||
}
|
||||
let endpointHost = NWEndpoint.Host(host)
|
||||
let connection = NWConnection(
|
||||
host: endpointHost, port: endpointPort, using: .tcp)
|
||||
let queue = DispatchQueue(
|
||||
label: "app.chanora.macos_permissions.local_network_check")
|
||||
|
||||
var didComplete = false
|
||||
|
||||
func finish(_ state: String) {
|
||||
guard !didComplete else { return }
|
||||
didComplete = true
|
||||
connection.cancel()
|
||||
result(state)
|
||||
}
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .waiting, .failed:
|
||||
if connection.currentPath?.unsatisfiedReason
|
||||
== .localNetworkDenied {
|
||||
finish("Denied")
|
||||
} else {
|
||||
finish("NotDetermined")
|
||||
}
|
||||
case .ready:
|
||||
finish("Granted")
|
||||
case .cancelled:
|
||||
finish("NotDetermined")
|
||||
case .setup, .preparing:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection.start(queue: queue)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
if !didComplete {
|
||||
if connection.currentPath?.unsatisfiedReason
|
||||
== .localNetworkDenied {
|
||||
finish("Denied")
|
||||
} else {
|
||||
finish("NotDetermined")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Notifications
|
||||
// =========================================================================
|
||||
@@ -399,8 +304,6 @@ class MainFlutterWindow: NSWindow {
|
||||
|
||||
// Register the macOS permissions MethodChannel handler.
|
||||
MacOSPermissionsHandler.register(with: flutterViewController.registrar(forPlugin: "MacOSPermissionsHandler"))
|
||||
// Register the macOS audio-lifecycle MethodChannel handler (closes the iOS/macOS asymmetry in SysRS-051).
|
||||
MacOSAudioLifecycle.register(with: flutterViewController.registrar(forPlugin: "MacOSAudioLifecycle"))
|
||||
|
||||
super.awakeFromNib()
|
||||
}
|
||||
|
||||
@@ -2,26 +2,12 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Release builds omit cs.allow-jit (Flutter hot-reload only). They keep
|
||||
network.server because the macOS App Sandbox classifies UDP bind()
|
||||
against a local port - including the ephemeral 0.0.0.0:0 that
|
||||
tsclientlib's tokio::net::UdpSocket::bind() issues for outbound
|
||||
voice traffic - as a server operation that requires
|
||||
com.apple.security.network.server, regardless of whether the
|
||||
socket is later used only to sendto() a remote peer. Without it,
|
||||
bind() returns EPERM and the sandbox log records
|
||||
"Sandbox: chanora(...) deny(1) network-bind". network.client
|
||||
alone gates outbound connect()-style flows (TCP, connected UDP)
|
||||
and is insufficient for the bind()-then-sendto() pattern Tokio's
|
||||
UdpSocket uses. See Apple's App Sandbox entitlement reference:
|
||||
"Network Server" covers any process that listens on, or binds
|
||||
to, a network port. -->
|
||||
<!-- Release builds omit the cs.allow-jit + network.server entitlements
|
||||
used only by Flutter's debug hot-reload. -->
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import CoreML
|
||||
import Darwin
|
||||
import Foundation
|
||||
import SileroCoreML
|
||||
|
||||
@@ -97,136 +96,3 @@ public func chanoraSileroVadFreeString(_ string: UnsafeMutablePointer<CChar>?) {
|
||||
guard let string else { return }
|
||||
free(string)
|
||||
}
|
||||
|
||||
@objc public final class ChanoraSileroSelfTest: NSObject {
|
||||
// Validates the same code path the Rust framework uses: dlsym(RTLD_DEFAULT) for all
|
||||
// six @_cdecl symbols, then exercises create -> reset -> process -> destroy. Catches
|
||||
// the dead-strip / linker-export class of bug that broke TestFlight; calling the Swift
|
||||
// functions directly would mask it because direct calls bypass the dynamic symbol table.
|
||||
@objc public static func run() {
|
||||
let started = DispatchTime.now()
|
||||
|
||||
// Static linker references: keep the Swift compiler / linker from
|
||||
// dead-stripping the @_cdecl symbols under Whole-Module-Optimization
|
||||
// + LTO in Archive builds. dlsym(RTLD_DEFAULT) below does NOT count
|
||||
// as a static reference for the dead-stripper — these `_ = ` lines
|
||||
// do. Without them, TestFlight builds shipped without the symbols
|
||||
// even though Debug builds (no LTO) worked.
|
||||
//
|
||||
// The `withoutActuallyEscaping` dance prevents the optimizer from
|
||||
// proving the references are unused: assigning the function value
|
||||
// to a `@convention(c)` typealias forces address-taken semantics.
|
||||
_ = unsafeBitCast(
|
||||
chanoraSileroVadCreate as @convention(c) () -> UnsafeMutableRawPointer?,
|
||||
to: UnsafeRawPointer.self,
|
||||
)
|
||||
_ = unsafeBitCast(
|
||||
chanoraSileroVadDestroy as @convention(c) (UnsafeMutableRawPointer?) -> Void,
|
||||
to: UnsafeRawPointer.self,
|
||||
)
|
||||
_ = unsafeBitCast(
|
||||
chanoraSileroVadReset as @convention(c) (UnsafeMutableRawPointer?) -> Int32,
|
||||
to: UnsafeRawPointer.self,
|
||||
)
|
||||
_ = unsafeBitCast(
|
||||
chanoraSileroVadProcess
|
||||
as @convention(c) (
|
||||
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int,
|
||||
UnsafeMutablePointer<Float>?
|
||||
) -> Int32,
|
||||
to: UnsafeRawPointer.self,
|
||||
)
|
||||
_ = unsafeBitCast(
|
||||
chanoraSileroVadLastError as @convention(c) () -> UnsafeMutablePointer<CChar>?,
|
||||
to: UnsafeRawPointer.self,
|
||||
)
|
||||
_ = unsafeBitCast(
|
||||
chanoraSileroVadFreeString as @convention(c) (UnsafeMutablePointer<CChar>?) -> Void,
|
||||
to: UnsafeRawPointer.self,
|
||||
)
|
||||
|
||||
typealias CreateFn = @convention(c) () -> UnsafeMutableRawPointer?
|
||||
typealias DestroyFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
|
||||
typealias ResetFn = @convention(c) (UnsafeMutableRawPointer?) -> Int32
|
||||
typealias ProcessFn = @convention(c) (
|
||||
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int, UnsafeMutablePointer<Float>?
|
||||
) -> Int32
|
||||
typealias LastErrorFn = @convention(c) () -> UnsafeMutablePointer<CChar>?
|
||||
typealias FreeStringFn = @convention(c) (UnsafeMutablePointer<CChar>?) -> Void
|
||||
|
||||
func resolve<T>(_ name: String, as type: T.Type) -> T? {
|
||||
guard let raw = dlsym(UnsafeMutableRawPointer(bitPattern: -2), name) else {
|
||||
return nil
|
||||
}
|
||||
return unsafeBitCast(raw, to: type)
|
||||
}
|
||||
|
||||
let names = [
|
||||
"chanora_silero_vad_create",
|
||||
"chanora_silero_vad_destroy",
|
||||
"chanora_silero_vad_reset",
|
||||
"chanora_silero_vad_process",
|
||||
"chanora_silero_vad_last_error",
|
||||
"chanora_silero_vad_free_string",
|
||||
]
|
||||
let missing = names.filter { dlsym(UnsafeMutableRawPointer(bitPattern: -2), $0) == nil }
|
||||
if !missing.isEmpty {
|
||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED dlsym missing=\(missing.joined(separator: ","))")
|
||||
return
|
||||
}
|
||||
|
||||
guard
|
||||
let create = resolve("chanora_silero_vad_create", as: CreateFn.self),
|
||||
let destroy = resolve("chanora_silero_vad_destroy", as: DestroyFn.self),
|
||||
let reset = resolve("chanora_silero_vad_reset", as: ResetFn.self),
|
||||
let process = resolve("chanora_silero_vad_process", as: ProcessFn.self),
|
||||
let lastError = resolve("chanora_silero_vad_last_error", as: LastErrorFn.self),
|
||||
let freeString = resolve("chanora_silero_vad_free_string", as: FreeStringFn.self)
|
||||
else {
|
||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED unsafeBitCast resolution")
|
||||
return
|
||||
}
|
||||
|
||||
func readError() -> String {
|
||||
guard let ptr = lastError() else { return "unknown" }
|
||||
let msg = String(cString: ptr)
|
||||
freeString(ptr)
|
||||
return msg
|
||||
}
|
||||
|
||||
guard let handle = create() else {
|
||||
let elapsedMs = elapsedMs(since: started)
|
||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED at create err=\(readError()) elapsed_ms=\(elapsedMs)")
|
||||
return
|
||||
}
|
||||
|
||||
let resetRc = reset(handle)
|
||||
if resetRc != 0 {
|
||||
destroy(handle)
|
||||
let elapsedMs = elapsedMs(since: started)
|
||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED at reset rc=\(resetRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
|
||||
return
|
||||
}
|
||||
|
||||
let chunkSize = SileroVADRunner.chunkSize
|
||||
var probability: Float = 0
|
||||
let samples = [Float](repeating: 0, count: chunkSize)
|
||||
let processRc = samples.withUnsafeBufferPointer { buf -> Int32 in
|
||||
process(handle, buf.baseAddress, chunkSize, &probability)
|
||||
}
|
||||
|
||||
destroy(handle)
|
||||
|
||||
let elapsedMs = elapsedMs(since: started)
|
||||
if processRc == 0 {
|
||||
NSLog("chanora_flutter: SileroCoreML self-test OK probability=\(probability) elapsed_ms=\(elapsedMs)")
|
||||
} else {
|
||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED at process rc=\(processRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func elapsedMs(since start: DispatchTime) -> String {
|
||||
let ns = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds
|
||||
return String(format: "%.1f", Double(ns) / 1_000_000.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,25 +52,19 @@ Pod::Spec.new do |s|
|
||||
|
||||
echo "[chanora_bridge.podspec] cargo build aarch64-apple-darwin"
|
||||
cd "$REPO_ROOT"
|
||||
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \\
|
||||
PATH="$HOME/.cargo/bin:$PATH" \\
|
||||
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\
|
||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||
LIBOPUS_STATIC=1 \\
|
||||
LIBOPUS_NO_PKG=1 \\
|
||||
CARGO_PROFILE_RELEASE_DEBUG=true \\
|
||||
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
|
||||
CARGO_PROFILE_RELEASE_STRIP=false \\
|
||||
cargo build --release --target aarch64-apple-darwin -p chanora_bridge
|
||||
|
||||
echo "[chanora_bridge.podspec] cargo build x86_64-apple-darwin"
|
||||
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \\
|
||||
PATH="$HOME/.cargo/bin:$PATH" \\
|
||||
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\
|
||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||
LIBOPUS_STATIC=1 \\
|
||||
LIBOPUS_NO_PKG=1 \\
|
||||
CARGO_PROFILE_RELEASE_DEBUG=true \\
|
||||
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
|
||||
CARGO_PROFILE_RELEASE_STRIP=false \\
|
||||
cargo build --release --target x86_64-apple-darwin -p chanora_bridge
|
||||
|
||||
if [ ! -f "$BRIDGE_ARM64" ]; then
|
||||
@@ -118,19 +112,7 @@ PLIST
|
||||
|
||||
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \\
|
||||
"$FW/Versions/A/chanora_bridge"
|
||||
|
||||
# Generate the framework's dSYM bundle. Apple's archive validator
|
||||
# rejects uploads when an embedded framework has no matching dSYM
|
||||
# (UUID lookup miss in the archive's dSYMs/ folder). dsymutil reads
|
||||
# the DWARF that cargo emitted (enabled by [profile.release]
|
||||
# debug = true at the workspace root) and writes the bundle next
|
||||
# to the framework. We then strip the in-framework binary so the
|
||||
# shipped app stays slim; symbols live in the dSYM bundle, which
|
||||
# is the layout xcodebuild -exportArchive and notarisation expect.
|
||||
rm -rf "$FW.dSYM"
|
||||
xcrun dsymutil "$FW/Versions/A/chanora_bridge" -o "$FW.dSYM"
|
||||
xcrun strip -S -x "$FW/Versions/A/chanora_bridge"
|
||||
echo "[chanora_bridge.podspec] framework + dSYM ready at $FW"
|
||||
echo "[chanora_bridge.podspec] framework ready at $FW"
|
||||
SCRIPT
|
||||
|
||||
# Pod CocoaPods picks this up; the framework gets embedded into
|
||||
@@ -159,54 +141,44 @@ PLIST
|
||||
|
||||
echo "[chanora_bridge script_phase] cargo build aarch64-apple-darwin"
|
||||
cd "$REPO_ROOT"
|
||||
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \
|
||||
PATH="$HOME/.cargo/bin:$PATH" \
|
||||
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \
|
||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||
LIBOPUS_STATIC=1 \
|
||||
LIBOPUS_NO_PKG=1 \
|
||||
CARGO_PROFILE_RELEASE_DEBUG=true \
|
||||
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \
|
||||
CARGO_PROFILE_RELEASE_STRIP=false \
|
||||
cargo build --release --target aarch64-apple-darwin -p chanora_bridge
|
||||
|
||||
echo "[chanora_bridge script_phase] cargo build x86_64-apple-darwin"
|
||||
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \
|
||||
PATH="$HOME/.cargo/bin:$PATH" \
|
||||
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \
|
||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||
LIBOPUS_STATIC=1 \
|
||||
LIBOPUS_NO_PKG=1 \
|
||||
CARGO_PROFILE_RELEASE_DEBUG=true \
|
||||
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \
|
||||
CARGO_PROFILE_RELEASE_STRIP=false \
|
||||
cargo build --release --target x86_64-apple-darwin -p chanora_bridge
|
||||
|
||||
cd "$REPO_ROOT/apps/chanora_flutter/macos"
|
||||
FW=Frameworks/chanora_bridge.framework
|
||||
FW_UP_TO_DATE=0
|
||||
|
||||
# Skip the wrap step if the framework's binary is already
|
||||
# up-to-date with the cargo output (fast no-op on incremental
|
||||
# builds where Rust didn't change). We still publish the dSYM
|
||||
# into DWARF_DSYM_FOLDER_PATH below so archive builds always
|
||||
# have the symbols, even when the framework itself is cached.
|
||||
# builds where Rust didn't change).
|
||||
if [ -f "$FW/Versions/A/chanora_bridge" ] && [ "$FW/Versions/A/chanora_bridge" -nt "$BRIDGE_ARM64" ] && [ "$FW/Versions/A/chanora_bridge" -nt "$BRIDGE_X86_64" ]; then
|
||||
echo "[chanora_bridge script_phase] framework already up-to-date"
|
||||
FW_UP_TO_DATE=1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$FW_UP_TO_DATE" = 0 ]; then
|
||||
# Create universal binary with lipo.
|
||||
mkdir -p "$(dirname "$UNIVERSAL")"
|
||||
lipo -create "$BRIDGE_ARM64" "$BRIDGE_X86_64" -output "$UNIVERSAL"
|
||||
# Create universal binary with lipo.
|
||||
mkdir -p "$(dirname "$UNIVERSAL")"
|
||||
lipo -create "$BRIDGE_ARM64" "$BRIDGE_X86_64" -output "$UNIVERSAL"
|
||||
|
||||
rm -rf "$FW"
|
||||
mkdir -p "$FW/Versions/A/Resources"
|
||||
ln -sfh A "$FW/Versions/Current"
|
||||
ln -sfh Versions/Current/Resources "$FW/Resources"
|
||||
cp "$UNIVERSAL" "$FW/Versions/A/chanora_bridge"
|
||||
ln -sfh Versions/Current/chanora_bridge "$FW/chanora_bridge"
|
||||
rm -rf "$FW"
|
||||
mkdir -p "$FW/Versions/A/Resources"
|
||||
ln -sfh A "$FW/Versions/Current"
|
||||
ln -sfh Versions/Current/Resources "$FW/Resources"
|
||||
cp "$UNIVERSAL" "$FW/Versions/A/chanora_bridge"
|
||||
ln -sfh Versions/Current/chanora_bridge "$FW/chanora_bridge"
|
||||
|
||||
cat > "$FW/Versions/A/Resources/Info.plist" <<PLIST
|
||||
cat > "$FW/Versions/A/Resources/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
@@ -223,24 +195,9 @@ PLIST
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \
|
||||
"$FW/Versions/A/chanora_bridge"
|
||||
rm -rf "$FW.dSYM"
|
||||
xcrun dsymutil "$FW/Versions/A/chanora_bridge" -o "$FW.dSYM"
|
||||
xcrun strip -S -x "$FW/Versions/A/chanora_bridge"
|
||||
echo "[chanora_bridge script_phase] framework refreshed (with dSYM)"
|
||||
fi
|
||||
|
||||
# Publish the dSYM into Xcode's archive dSYM folder on every
|
||||
# build (cached or not). See the iOS podspec for the full
|
||||
# rationale — same constraint applies to macOS notarisation
|
||||
# and archive-based distribution.
|
||||
if [ -n "${DWARF_DSYM_FOLDER_PATH:-}" ] && [ -d "$FW.dSYM" ]; then
|
||||
mkdir -p "$DWARF_DSYM_FOLDER_PATH"
|
||||
rm -rf "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
|
||||
cp -R "$FW.dSYM" "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
|
||||
echo "[chanora_bridge script_phase] dSYM published to $DWARF_DSYM_FOLDER_PATH"
|
||||
fi
|
||||
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \
|
||||
"$FW/Versions/A/chanora_bridge"
|
||||
echo "[chanora_bridge script_phase] framework refreshed"
|
||||
SCRIPT
|
||||
:execution_position => :before_compile,
|
||||
}
|
||||
|
||||
@@ -262,46 +262,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -834,14 +794,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.0"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -75,7 +75,6 @@ dependencies:
|
||||
# DEC-003 iOS 13 floor; haptic_kit supports iOS 12+).
|
||||
haptic_kit: ^1.0.0
|
||||
flutter_foreground_task: ^9.2.2
|
||||
flutter_local_notifications: ^22.0.0
|
||||
url_launcher: ^6.3.2
|
||||
shared_preferences: ^2.5.5
|
||||
share_plus: ^13.1.0
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/bin/sh
|
||||
# verify_silero_exports.sh
|
||||
#
|
||||
# Asserts that all six chanora_silero_vad_* C symbols that the Rust
|
||||
# chanora_bridge framework resolves via dlsym(RTLD_DEFAULT) are present
|
||||
# in the linked app binary's dynamic export table — verified per
|
||||
# architecture slice for macOS universal builds, because `nm` on a
|
||||
# universal Mach-O without -arch will succeed if a symbol exists in ANY
|
||||
# slice, not every slice. A missing symbol in just the x86_64 slice
|
||||
# would silently break Intel Macs.
|
||||
#
|
||||
# Without this check, Xcode Archive's -dead_strip can remove these
|
||||
# Swift @_cdecl symbols (no Swift caller exists) and CoreML VAD falls
|
||||
# back to WebRTC on TestFlight/App Store with no compile-time,
|
||||
# link-time, or runtime warning. We hit that bug once; this script
|
||||
# ensures we never ship it again.
|
||||
#
|
||||
# Runs as an Xcode build phase after Link Binary, on iOS and macOS.
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z "${TARGET_BUILD_DIR}" ] || [ -z "${EXECUTABLE_PATH}" ]; then
|
||||
echo "error: verify_silero_exports.sh requires TARGET_BUILD_DIR and EXECUTABLE_PATH (run from Xcode build phase)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BINARY="${TARGET_BUILD_DIR}/${EXECUTABLE_PATH}"
|
||||
|
||||
if [ ! -f "${BINARY}" ]; then
|
||||
echo "error: app binary not found at ${BINARY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REQUIRED_SYMBOLS="
|
||||
_chanora_silero_vad_create
|
||||
_chanora_silero_vad_destroy
|
||||
_chanora_silero_vad_reset
|
||||
_chanora_silero_vad_process
|
||||
_chanora_silero_vad_last_error
|
||||
_chanora_silero_vad_free_string
|
||||
"
|
||||
|
||||
# Enumerate slices. `lipo -archs` prints arches space-separated for
|
||||
# universal Mach-O; for thin binaries it prints the single arch.
|
||||
ARCHS=$(xcrun lipo -archs "${BINARY}" 2>/dev/null || echo "")
|
||||
if [ -z "${ARCHS}" ]; then
|
||||
echo "error: xcrun lipo -archs failed for ${BINARY}; cannot enumerate slices" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FAILED=0
|
||||
for arch in ${ARCHS}; do
|
||||
EXPORTED=$(xcrun nm -arch "${arch}" -gU "${BINARY}" 2>/dev/null | awk '{print $NF}')
|
||||
if [ -z "${EXPORTED}" ]; then
|
||||
echo "error: xcrun nm produced no output for arch=${arch} on ${BINARY}" >&2
|
||||
FAILED=1
|
||||
continue
|
||||
fi
|
||||
|
||||
MISSING=""
|
||||
for sym in ${REQUIRED_SYMBOLS}; do
|
||||
if ! echo "${EXPORTED}" | grep -qx "${sym}"; then
|
||||
MISSING="${MISSING} ${sym}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "${MISSING}" ]; then
|
||||
echo "error: chanora_silero_vad exports missing from $(basename "${BINARY}") [arch=${arch}]:${MISSING}" >&2
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${FAILED}" -ne 0 ]; then
|
||||
echo "error: Rust dlsym(RTLD_DEFAULT) resolution will fail and CoreML VAD will fall back to WebRTC." >&2
|
||||
echo "error: Check OTHER_LDFLAGS -exported_symbol entries in Flutter/*.xcconfig and the @_cdecl exports in Runner/SileroCoreMLBridge.swift." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "verify_silero_exports: all 6 chanora_silero_vad_* symbols present in $(basename "${BINARY}") [arches: ${ARCHS}]"
|
||||
@@ -4,7 +4,7 @@ import 'package:chanora_flutter/services/audio_lifecycle_service.dart';
|
||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||
|
||||
void main() {
|
||||
test('parseBridgeAudioRoute maps iOS-classified route strings', () {
|
||||
test('parseBridgeAudioRoute maps platform route names', () {
|
||||
expect(parseBridgeAudioRoute('Earpiece'), rust.BridgeAudioRoute.earpiece);
|
||||
expect(parseBridgeAudioRoute('Speaker'), rust.BridgeAudioRoute.speaker);
|
||||
expect(
|
||||
@@ -22,34 +22,4 @@ void main() {
|
||||
expect(parseBridgeAudioRoute('Unknown'), rust.BridgeAudioRoute.unknown);
|
||||
expect(parseBridgeAudioRoute('Other'), rust.BridgeAudioRoute.unknown);
|
||||
});
|
||||
|
||||
test('parseBridgeAudioRoute maps Android UsbHeadset to wiredHeadset', () {
|
||||
// AndroidAudioLifecycleController.classifyDevice emits 'UsbHeadset' for
|
||||
// AudioDeviceInfo.TYPE_USB_HEADSET. USB audio is functionally a
|
||||
// wired-class device — the Kotlin classifier's own preference ordering
|
||||
// (line 176 of AndroidAudioLifecycleController.kt) groups it with
|
||||
// WiredHeadset/BluetoothHfp/BluetoothA2dp.
|
||||
expect(parseBridgeAudioRoute('UsbHeadset'),
|
||||
rust.BridgeAudioRoute.wiredHeadset);
|
||||
});
|
||||
|
||||
test('parseBridgeAudioRoute maps Android Hdmi to unknown', () {
|
||||
// HDMI is a display-out transport, not a voice-call audio path; no
|
||||
// existing BridgeAudioRoute variant fits. Treat as unknown rather
|
||||
// than misclassify as Speaker.
|
||||
expect(parseBridgeAudioRoute('Hdmi'), rust.BridgeAudioRoute.unknown);
|
||||
});
|
||||
|
||||
test('wireMacosAudioLifecycle is a no-op on non-macOS and registers on macOS', () {
|
||||
// MethodChannel needs a binary messenger, which requires the test
|
||||
// binding to be initialised first.
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
// Channel name constant matches the Swift side (MacOSAudioLifecycle.swift).
|
||||
expect(macosAudioLifecycleChannelName, 'chanora/macos_audio_lifecycle');
|
||||
// The wire is a no-op on the test platform (CI defaults to host OS
|
||||
// which may be macOS or Linux). On Linux it returns early; on macOS
|
||||
// it installs a handler. Either way, it must not throw.
|
||||
expect(() => wireMacosAudioLifecycle(), returnsNormally);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -301,61 +301,6 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-300: checkLocalNetworkAccess() emits outbound '
|
||||
'checkLocalNetworkAccess MethodCall with host and port arguments',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
outgoingResponder = (call) async {
|
||||
if (call.method == methodCheckLocalNetworkAccess) {
|
||||
return 'Denied';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
final result = await svc.checkLocalNetworkAccess(
|
||||
host: '192.168.1.42',
|
||||
port: 9987,
|
||||
);
|
||||
|
||||
final calls = outgoingCalls
|
||||
.where((c) => c.method == methodCheckLocalNetworkAccess)
|
||||
.toList();
|
||||
expect(calls, hasLength(1));
|
||||
final args = calls.single.arguments as Map;
|
||||
expect(args['host'], '192.168.1.42');
|
||||
expect(args['port'], 9987);
|
||||
expect(result, MacOSLocalNetworkState.denied);
|
||||
expect(svc.localNetworkState.value, MacOSLocalNetworkState.denied);
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-300: checkLocalNetworkAccess() parses Granted and updates '
|
||||
'localNetworkState',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
outgoingResponder = (call) async {
|
||||
if (call.method == methodCheckLocalNetworkAccess) {
|
||||
return 'Granted';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
final result = await svc.checkLocalNetworkAccess(
|
||||
host: 'ts.example.com',
|
||||
port: 9987,
|
||||
);
|
||||
|
||||
expect(result, MacOSLocalNetworkState.granted);
|
||||
expect(svc.localNetworkState.value, MacOSLocalNetworkState.granted);
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SWE4-UV / SysRS-166: requestNotifications() emits outbound '
|
||||
'requestNotifications MethodCall; returns the platform response',
|
||||
@@ -557,30 +502,4 @@ void main() {
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-300: checkLocalNetworkAccess() returns cached state '
|
||||
'when the channel throws',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
outgoingResponder = (call) async {
|
||||
if (call.method == methodCheckLocalNetworkAccess) {
|
||||
throw PlatformException(code: 'probe-failed');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Probe path failures (e.g. NWConnection couldn't establish a
|
||||
// listener, or the Swift side threw) must not crash callers
|
||||
// — they must fall back to whatever the service already cached.
|
||||
final result = await svc.checkLocalNetworkAccess(
|
||||
host: '127.0.0.1',
|
||||
port: 9987,
|
||||
);
|
||||
|
||||
expect(result, isNotNull);
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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');
|
||||
});
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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 {}
|
||||
@@ -1,201 +0,0 @@
|
||||
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/chat_panel.dart';
|
||||
import 'package:chanora_flutter/widgets/chat_views.dart';
|
||||
|
||||
void main() {
|
||||
rust.BridgeSnapshot snapshot() {
|
||||
final channelId = BigInt.from(10);
|
||||
return rust.BridgeSnapshot(
|
||||
serverName: 'Server',
|
||||
welcomeMessage: '',
|
||||
platform: '',
|
||||
version: '',
|
||||
channels: [
|
||||
rust.BridgeChannel(
|
||||
id: channelId,
|
||||
parent: BigInt.zero,
|
||||
name: 'Lobby',
|
||||
order: 0,
|
||||
hasPassword: false,
|
||||
neededTalkPower: 0,
|
||||
),
|
||||
],
|
||||
clients: [
|
||||
rust.BridgeClient(
|
||||
id: BigInt.one,
|
||||
channel: channelId,
|
||||
name: 'Me',
|
||||
inputMuted: false,
|
||||
outputMuted: false,
|
||||
isSpeaking: false,
|
||||
isServerQuery: false,
|
||||
talkPower: 0,
|
||||
talkPowerGranted: true,
|
||||
),
|
||||
],
|
||||
ownClientId: BigInt.one,
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('inline chat panel renders target, messages, and close action', (
|
||||
tester,
|
||||
) async {
|
||||
var closed = false;
|
||||
final messages = [
|
||||
ChatEntry(
|
||||
senderId: BigInt.from(2),
|
||||
senderName: 'Alice',
|
||||
message: 'Hello from channel',
|
||||
target: const rust.BridgeMessageTarget.channel(),
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: ChatPanel(
|
||||
messages: messages,
|
||||
snapshot: snapshot(),
|
||||
target: const rust.BridgeMessageTarget.channel(),
|
||||
clientName: '',
|
||||
onClose: () => closed = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('# Lobby'), findsOneWidget);
|
||||
expect(find.text('Hello from channel'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Close chat'));
|
||||
await tester.pump();
|
||||
|
||||
expect(closed, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('chat detail restores target drafts when the target changes', (
|
||||
tester,
|
||||
) async {
|
||||
String? savedDraft;
|
||||
final messages = <ChatEntry>[];
|
||||
|
||||
Widget detail({
|
||||
required rust.BridgeMessageTarget target,
|
||||
required String? restoredDraft,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: ChatDetailView(
|
||||
messages: messages,
|
||||
snapshot: snapshot(),
|
||||
target: target,
|
||||
clientName: '',
|
||||
currentChannelId: BigInt.from(10),
|
||||
channelName: 'Lobby',
|
||||
restoredDraft: restoredDraft,
|
||||
onDraftChanged: (text) => savedDraft = text,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(
|
||||
detail(
|
||||
target: const rust.BridgeMessageTarget.channel(),
|
||||
restoredDraft: 'channel draft',
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
||||
'channel draft',
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'typed channel draft');
|
||||
await tester.pumpWidget(
|
||||
detail(
|
||||
target: const rust.BridgeMessageTarget.server(),
|
||||
restoredDraft: 'server draft',
|
||||
),
|
||||
);
|
||||
|
||||
expect(savedDraft, 'typed channel draft');
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
||||
'server draft',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'chat detail propagates empty draft when the user clears it before switching target',
|
||||
(tester) async {
|
||||
// Regression: previously, _ChatDetailViewState only emitted
|
||||
// onDraftChanged when the text was non-empty. If the user
|
||||
// restored a saved draft, deleted it, then switched target,
|
||||
// the stale entry stayed in the parent's draft map and
|
||||
// resurrected on the next target swap.
|
||||
String? savedDraft = 'sentinel-unset';
|
||||
final messages = <ChatEntry>[];
|
||||
|
||||
Widget detail({
|
||||
required rust.BridgeMessageTarget target,
|
||||
required String? restoredDraft,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: ChatDetailView(
|
||||
messages: messages,
|
||||
snapshot: snapshot(),
|
||||
target: target,
|
||||
clientName: '',
|
||||
currentChannelId: BigInt.from(10),
|
||||
channelName: 'Lobby',
|
||||
restoredDraft: restoredDraft,
|
||||
onDraftChanged: (text) => savedDraft = text,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(
|
||||
detail(
|
||||
target: const rust.BridgeMessageTarget.channel(),
|
||||
restoredDraft: 'previously saved channel draft',
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
||||
'previously saved channel draft',
|
||||
);
|
||||
|
||||
// User clears the field, then switches target.
|
||||
await tester.enterText(find.byType(TextField), '');
|
||||
await tester.pumpWidget(
|
||||
detail(
|
||||
target: const rust.BridgeMessageTarget.server(),
|
||||
restoredDraft: null,
|
||||
),
|
||||
);
|
||||
|
||||
// The empty string MUST reach the parent so the stale entry
|
||||
// is overwritten in the draft map. With the previous guarded
|
||||
// implementation, savedDraft would still hold the sentinel.
|
||||
expect(
|
||||
savedDraft,
|
||||
'',
|
||||
reason: 'empty draft must overwrite stale entry on target swap',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -455,7 +455,7 @@ void main() {
|
||||
channelName: '',
|
||||
clientName: 'Alpha',
|
||||
),
|
||||
'Poke message optional...',
|
||||
'Poke message...',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -737,179 +737,6 @@ void main() {
|
||||
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', () {
|
||||
expect(
|
||||
canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null),
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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_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);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: PokeNotificationSettingsDialog(preferences: preferences),
|
||||
),
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -52,13 +52,11 @@ void main() {
|
||||
String welcomeMessage = '',
|
||||
BigInt? ownClientId,
|
||||
BigInt? currentVoiceChannelId,
|
||||
Set<BigInt> unreadChannelIds = const {},
|
||||
rust.BridgeAudioStats? audioStats,
|
||||
bool enableClientLongPressMenu = false,
|
||||
ValueChanged<rust.BridgeClient>? onOpenClientInfo,
|
||||
ValueChanged<rust.BridgeClient>? onOpenClientChat,
|
||||
ValueChanged<rust.BridgeClient>? onOpenClientPoke,
|
||||
ValueChanged<rust.BridgeChannel>? onOpenChannelChat,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
@@ -81,14 +79,12 @@ void main() {
|
||||
localOutputMuted: false,
|
||||
hasJoinPending: false,
|
||||
canJoinVoiceChannel: true,
|
||||
unreadChannelIds: unreadChannelIds,
|
||||
onJoinChannel: (_) {},
|
||||
onJoinChannelWithPassword: (_) {},
|
||||
enableClientLongPressMenu: enableClientLongPressMenu,
|
||||
onOpenClientInfo: onOpenClientInfo,
|
||||
onOpenClientChat: onOpenClientChat,
|
||||
onOpenClientPoke: onOpenClientPoke,
|
||||
onOpenChannelChat: onOpenChannelChat,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -137,32 +133,6 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders unread dot on channels with unread chat messages', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Lobby')],
|
||||
clients: const [],
|
||||
unreadChannelIds: {BigInt.one},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Container &&
|
||||
widget.constraints?.maxWidth == 8 &&
|
||||
widget.constraints?.maxHeight == 8 &&
|
||||
widget.decoration is BoxDecoration &&
|
||||
(widget.decoration! as BoxDecoration).shape == BoxShape.circle,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('collapsing a channel hides users and child channels', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -580,7 +550,6 @@ void main() {
|
||||
localOutputMuted: false,
|
||||
hasJoinPending: false,
|
||||
canJoinVoiceChannel: true,
|
||||
unreadChannelIds: const {},
|
||||
onJoinChannel: (channel) => tapped = channel,
|
||||
onJoinChannelWithPassword: (_) {},
|
||||
),
|
||||
@@ -608,64 +577,6 @@ void main() {
|
||||
expect(tapped!.neededTalkPower, 12);
|
||||
});
|
||||
|
||||
testWidgets('channel context menu opens chat without replacing voice join', (
|
||||
tester,
|
||||
) async {
|
||||
final lobby = channel(id: 1, name: 'Lobby');
|
||||
rust.BridgeChannel? joined;
|
||||
rust.BridgeChannel? openedChat;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: SnapshotView(
|
||||
snapshot: rust.BridgeSnapshot(
|
||||
serverName: 'Server',
|
||||
welcomeMessage: '',
|
||||
platform: '',
|
||||
version: '',
|
||||
channels: [lobby],
|
||||
clients: const [],
|
||||
ownClientId: BigInt.one,
|
||||
),
|
||||
audioStats: null,
|
||||
currentVoiceChannelId: null,
|
||||
pendingVoiceChannelId: null,
|
||||
localInputMuted: false,
|
||||
localOutputMuted: false,
|
||||
hasJoinPending: false,
|
||||
canJoinVoiceChannel: true,
|
||||
unreadChannelIds: const {},
|
||||
onJoinChannel: (channel) => joined = channel,
|
||||
onJoinChannelWithPassword: (_) {},
|
||||
onOpenChannelChat: (channel) => openedChat = channel,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Lobby'));
|
||||
await tester.pump();
|
||||
|
||||
expect(joined, lobby);
|
||||
expect(openedChat, isNull);
|
||||
expect(find.text('Chat'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Lobby'), buttons: kSecondaryMouseButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Chat'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Chat'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(openedChat, lobby);
|
||||
});
|
||||
|
||||
testWidgets('separator spacers render as line painters without raw text', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -700,7 +611,6 @@ void main() {
|
||||
localOutputMuted: false,
|
||||
hasJoinPending: false,
|
||||
canJoinVoiceChannel: true,
|
||||
unreadChannelIds: const {},
|
||||
onJoinChannel: (_) {},
|
||||
onJoinChannelWithPassword: (_) {},
|
||||
),
|
||||
@@ -755,7 +665,6 @@ void main() {
|
||||
localOutputMuted: false,
|
||||
hasJoinPending: false,
|
||||
canJoinVoiceChannel: true,
|
||||
unreadChannelIds: const {},
|
||||
onJoinChannel: (_) {},
|
||||
onJoinChannelWithPassword: (_) {},
|
||||
),
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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]);
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
flutter_local_notifications_windows
|
||||
jni
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
|
||||
chanora_prefetch = { path = "../../crates/chanora_prefetch" }
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Used by integration tests to inspect the bookmark DB row layout
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
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)]
|
||||
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,
|
||||
}
|
||||
+343
-184
@@ -52,8 +52,6 @@ use chanora_state::channel_join::{
|
||||
ConnectionEpoch, JoinFailureKind,
|
||||
};
|
||||
|
||||
mod events;
|
||||
mod network_diagnostics;
|
||||
pub mod ptt;
|
||||
|
||||
pub use chanora_audio::{
|
||||
@@ -68,14 +66,49 @@ pub use chanora_diagnostics::{
|
||||
};
|
||||
pub use chanora_protocol::{
|
||||
ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig, DisconnectReason,
|
||||
MessageTarget, PokeStrength, ProtocolError, ServerActivity, ServerSnapshot,
|
||||
MessageTarget, ProtocolError, ServerActivity, ServerSnapshot,
|
||||
};
|
||||
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
|
||||
pub use events::{
|
||||
NetworkState, PersistedPttBinding, PttDescriptorSnapshot, SessionEvent, VoiceJoinErrorCode,
|
||||
VoiceJoinSyncState,
|
||||
};
|
||||
use network_diagnostics::NetworkDiagnostics;
|
||||
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
input_class: String::new(),
|
||||
key_label: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can arise during top-level orchestration.
|
||||
#[derive(Debug, Error)]
|
||||
@@ -114,11 +147,291 @@ pub enum CoreError {
|
||||
Ptt(#[from] ptt::PttControllerError),
|
||||
}
|
||||
|
||||
/// 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,
|
||||
},
|
||||
/// 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)]
|
||||
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)]
|
||||
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,
|
||||
}
|
||||
|
||||
/// Channel capacity for the broadcast events. Generous because
|
||||
/// reconnect cycles emit several events per attempt; if subscribers
|
||||
/// fall behind we'd rather skip than block the supervisor.
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
||||
|
||||
/// Network diagnostics snapshot collected across connection lifetimes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
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: Vec<String>,
|
||||
}
|
||||
|
||||
impl NetworkDiagnostics {
|
||||
fn record_connect(&mut self) {
|
||||
self.connect_count = self.connect_count.saturating_add(1);
|
||||
}
|
||||
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.remove(0);
|
||||
}
|
||||
self.loss_reasons.push(reason.to_string());
|
||||
}
|
||||
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.join(", ")
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
struct SupervisorInner {
|
||||
/// Optional cached AudioEngineConfig — set when start_audio is
|
||||
/// first called, used to re-create the engine after a reconnect.
|
||||
@@ -164,20 +477,12 @@ struct ConnectedState {
|
||||
local_output_muted: bool,
|
||||
}
|
||||
|
||||
async fn take_disconnect_state<T>(inner: &Arc<Mutex<Option<T>>>) -> Option<T> {
|
||||
inner.lock().await.take()
|
||||
}
|
||||
|
||||
fn normalize_channel_password(password: Option<String>) -> Option<String> {
|
||||
password
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
fn should_dispatch_text_message(message: &str, target: &MessageTarget) -> bool {
|
||||
!message.trim().is_empty() || matches!(target, MessageTarget::Poke(_))
|
||||
}
|
||||
|
||||
/// The top-level Chanora session. Owns at most one active server
|
||||
/// connection (DEC-006).
|
||||
#[derive(Clone)]
|
||||
@@ -677,7 +982,7 @@ impl ChanoraSession {
|
||||
message: String,
|
||||
target: MessageTarget,
|
||||
) -> Result<(), CoreError> {
|
||||
if !should_dispatch_text_message(&message, &target) {
|
||||
if message.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let guard = self.inner.lock().await;
|
||||
@@ -1573,7 +1878,8 @@ impl ChanoraSession {
|
||||
|
||||
/// Disconnect from the server. No-op if not connected.
|
||||
pub async fn disconnect(&self) -> Result<(), CoreError> {
|
||||
if let Some(mut state) = take_disconnect_state(&self.inner).await {
|
||||
let mut guard = self.inner.lock().await;
|
||||
if let Some(mut state) = guard.take() {
|
||||
// Signal the supervisor to exit (cancels any backoff sleep).
|
||||
if let Some(tx) = state.cancel_tx.take() {
|
||||
let _ = tx.send(());
|
||||
@@ -1589,7 +1895,7 @@ impl ChanoraSession {
|
||||
// Wait for the supervisor to wind down so we don't race
|
||||
// a redial against the explicit disconnect.
|
||||
if let Some(handle) = state.supervisor.take() {
|
||||
await_supervisor_shutdown(handle, SUPERVISOR_SHUTDOWN_TIMEOUT).await;
|
||||
let _ = handle.await;
|
||||
}
|
||||
let _ = self.events_tx.send(SessionEvent::Disconnected {
|
||||
reason: "user requested".to_string(),
|
||||
@@ -1628,22 +1934,6 @@ const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
/// Number of consecutive watchdog failures before the supervisor
|
||||
/// declares the connection lost.
|
||||
const WATCHDOG_MAX_MISSES: u32 = 3;
|
||||
const SUPERVISOR_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
async fn await_supervisor_shutdown(mut handle: JoinHandle<()>, timeout_duration: Duration) {
|
||||
if tokio::time::timeout(timeout_duration, &mut handle)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
timeout_ms = timeout_duration.as_millis() as u64,
|
||||
"supervisor did not stop before shutdown timeout"
|
||||
);
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
struct SupervisorContext {
|
||||
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
||||
@@ -1678,7 +1968,6 @@ fn spawn_event_forwarders(
|
||||
sender_name: msg.sender_name,
|
||||
message: msg.message,
|
||||
target: msg.target,
|
||||
poke_strength: msg.poke_strength,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1700,77 +1989,27 @@ fn spawn_event_forwarders(
|
||||
let mut rx = delta_rx;
|
||||
while let Some(delta) = rx.recv().await {
|
||||
let event = match delta {
|
||||
ProtocolDelta::ClientMoved {
|
||||
client_id,
|
||||
new_channel_id,
|
||||
} => SessionEvent::ClientMoved {
|
||||
client_id,
|
||||
new_channel_id,
|
||||
},
|
||||
ProtocolDelta::ClientJoined {
|
||||
client_id,
|
||||
channel_id,
|
||||
name,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} => SessionEvent::ClientJoined {
|
||||
client_id,
|
||||
channel_id,
|
||||
name,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
},
|
||||
ProtocolDelta::ClientMoved { client_id, new_channel_id } => {
|
||||
SessionEvent::ClientMoved { client_id, new_channel_id }
|
||||
}
|
||||
ProtocolDelta::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
ProtocolDelta::ClientLeft { client_id, name } => {
|
||||
SessionEvent::ClientLeft { client_id, name }
|
||||
}
|
||||
ProtocolDelta::ClientUpdated {
|
||||
client_id,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} => SessionEvent::ClientUpdated {
|
||||
client_id,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
},
|
||||
ProtocolDelta::ChannelAdded {
|
||||
id,
|
||||
parent,
|
||||
name,
|
||||
order,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} => SessionEvent::ChannelAdded {
|
||||
id,
|
||||
parent,
|
||||
name,
|
||||
order,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
},
|
||||
ProtocolDelta::ChannelRemoved { id } => SessionEvent::ChannelRemoved { id },
|
||||
ProtocolDelta::ChannelUpdated {
|
||||
id,
|
||||
name,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} => SessionEvent::ChannelUpdated {
|
||||
id,
|
||||
name,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
},
|
||||
ProtocolDelta::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
ProtocolDelta::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
|
||||
SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
|
||||
}
|
||||
ProtocolDelta::ChannelRemoved { id } => {
|
||||
SessionEvent::ChannelRemoved { id }
|
||||
}
|
||||
ProtocolDelta::ChannelUpdated { id, name, has_password, needed_talk_power } => {
|
||||
SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
|
||||
}
|
||||
};
|
||||
let _ = ev_tx.send(event);
|
||||
}
|
||||
@@ -2394,54 +2633,6 @@ mod tests {
|
||||
s.disconnect().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_state_take_releases_inner_lock_before_teardown() {
|
||||
let inner = Arc::new(Mutex::new(Some(())));
|
||||
|
||||
let state = super::take_disconnect_state(&inner).await;
|
||||
|
||||
assert_eq!(state, Some(()));
|
||||
assert!(inner.try_lock().is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supervisor_join_returns_after_shutdown_timeout() {
|
||||
let handle = tokio::spawn(async {
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
super::await_supervisor_shutdown(handle, Duration::from_millis(10)).await;
|
||||
|
||||
assert!(start.elapsed() < Duration::from_millis(100));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supervisor_shutdown_timeout_aborts_pending_task() {
|
||||
struct DropNotice(Option<tokio::sync::oneshot::Sender<()>>);
|
||||
|
||||
impl Drop for DropNotice {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tx) = self.0.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
|
||||
let handle = tokio::spawn(async move {
|
||||
let _notice = DropNotice(Some(dropped_tx));
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
|
||||
super::await_supervisor_shutdown(handle, Duration::from_millis(10)).await;
|
||||
|
||||
tokio::time::timeout(Duration::from_millis(100), dropped_rx)
|
||||
.await
|
||||
.expect("pending supervisor task should be aborted")
|
||||
.expect("drop notice should be delivered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_detects_in_channel_move() {
|
||||
use chanora_protocol::{ChannelInfo, ClientInfo};
|
||||
@@ -2539,38 +2730,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_poke_messages_are_dispatchable() {
|
||||
assert!(super::should_dispatch_text_message(
|
||||
"",
|
||||
&MessageTarget::Poke(42)
|
||||
));
|
||||
assert!(super::should_dispatch_text_message(
|
||||
" \t ",
|
||||
&MessageTarget::Poke(42)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_non_poke_messages_remain_suppressed() {
|
||||
assert!(!super::should_dispatch_text_message(
|
||||
"",
|
||||
&MessageTarget::Server
|
||||
));
|
||||
assert!(!super::should_dispatch_text_message(
|
||||
" ",
|
||||
&MessageTarget::Channel
|
||||
));
|
||||
assert!(!super::should_dispatch_text_message(
|
||||
"",
|
||||
&MessageTarget::Client(42)
|
||||
));
|
||||
assert!(super::should_dispatch_text_message(
|
||||
"hello",
|
||||
&MessageTarget::Channel
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_address_is_rejected() {
|
||||
let s = ChanoraSession::new();
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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]"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ crossbeam = { version = "0.8", default-features = false, features = ["alloc", "c
|
||||
# Desktop audio I/O for Windows capture/playback and Linux capture.
|
||||
# Linux playback uses SDL2; Apple platforms use direct VoiceProcessingIO
|
||||
# AudioUnits via `coreaudio-rs` for the voice path.
|
||||
cpal = "0.18.0"
|
||||
cpal = "0.17.3"
|
||||
|
||||
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
|
||||
# Direct CoreAudio AudioUnit access on Apple platforms (DEC-011 follow-up).
|
||||
@@ -58,7 +58,7 @@ ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dyn
|
||||
# JNI bindings to flip Android's AudioManager into MODE_IN_COMMUNICATION
|
||||
# when the voice-comm preset is requested. ndk_context is initialised
|
||||
# by the bridge crate's android_init shim.
|
||||
jni = { version = "0.22.4", default-features = false }
|
||||
jni = { version = "0.21", default-features = false }
|
||||
ndk-context = "0.1"
|
||||
# Oboe-rs (Google Oboe wrapper) for low-latency voice capture + playback.
|
||||
# Primary backend for SDD-111..SDD-115. The pre-compiled static library
|
||||
@@ -84,7 +84,7 @@ bytemuck = { version = "1", features = ["derive"] }
|
||||
# + WM_INPUT translation backed by a hidden message-only window, and
|
||||
# SetWindowsHookExW(WH_KEYBOARD_LL / WH_MOUSE_LL) fallback. Both
|
||||
# require a per-backend OS thread that owns a message pump.
|
||||
windows = { version = "0.62", features = [
|
||||
windows = { version = "0.54", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_LibraryLoader",
|
||||
@@ -104,7 +104,7 @@ tracing-subscriber = { version = "0.3", features = ["registry"] }
|
||||
# SDD-120 §3 — criterion bench harness (realtime_capture / opus_codec /
|
||||
# resampler). `harness = false` per bench entry below disables the
|
||||
# default libtest harness so criterion can install its own.
|
||||
criterion = "0.8"
|
||||
criterion = "0.5"
|
||||
# SDD-120 §3 item 1 — dhat is used as the global allocator inside
|
||||
# `benches/realtime_capture.rs` to count post-warmup heap allocations
|
||||
# on the realtime capture path. Dev-dep only — does NOT affect
|
||||
@@ -147,7 +147,7 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] }
|
||||
# Random token bytes for the portal handle_token / session_handle_token
|
||||
# options. The portal recommends fresh tokens to scope its own
|
||||
# object paths per call.
|
||||
rand = "0.10"
|
||||
rand = "0.8"
|
||||
# SDL2 audio for Linux. Replaces the cpal playback path on Linux only;
|
||||
# cpal stays in use for Linux capture and Windows capture/playback.
|
||||
# Apple platforms use direct VoiceProcessingIO AudioUnits. Rationale: the
|
||||
@@ -166,4 +166,4 @@ rand = "0.10"
|
||||
# libSDL2.so. Arch ships `sdl2-compat`; Debian/Ubuntu ship
|
||||
# `libsdl2-2.0-0`; Fedora ships `SDL2`. The chanora-flutter Linux
|
||||
# build documentation lists this as a runtime dependency.
|
||||
sdl2 = { version = "0.38", default-features = false }
|
||||
sdl2 = { version = "0.37", default-features = false }
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
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,6 +53,7 @@ use crate::mobile_voice_backend::{
|
||||
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
|
||||
SharingModeChoice, VoiceAudioParams,
|
||||
};
|
||||
use chanora_protocol::OutPacket;
|
||||
use tsclientlib::audio::AudioHandler;
|
||||
|
||||
use crate::{engine::SessionAudioId, AudioError};
|
||||
@@ -85,11 +86,40 @@ use crate::processor::AudioProcessor;
|
||||
|
||||
const RENDER_REF_SLOTS: usize = 4;
|
||||
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;
|
||||
|
||||
type RenderReferenceBuffer =
|
||||
crate::render_reference::RenderReferenceBuffer<RENDER_REF_SAMPLES, RENDER_REF_SLOTS>;
|
||||
struct RenderReferenceBuffer {
|
||||
buf: Box<[[f32; 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) ----
|
||||
//
|
||||
@@ -108,8 +138,9 @@ struct AndroidCaptureState {
|
||||
encoder: OpusEncoder,
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
@@ -133,7 +164,7 @@ struct AndroidCaptureState {
|
||||
|
||||
impl AndroidCaptureState {
|
||||
fn new(
|
||||
voice_out_tx: mpsc::Sender<chanora_protocol::OutPacket>,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
@@ -157,12 +188,9 @@ impl AndroidCaptureState {
|
||||
encoder,
|
||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||
voice_out_tx,
|
||||
frames_sent.clone(),
|
||||
"android",
|
||||
)?,
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
mic_gain,
|
||||
voice_activity_selector,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||
@@ -193,10 +221,8 @@ impl AndroidCaptureState {
|
||||
self.audio_processing_stats
|
||||
.record_callback_frames(samples.len() as u64);
|
||||
if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ {
|
||||
self.resample_capture_to_48k(samples);
|
||||
let resampled = std::mem::take(&mut self.resample_scratch);
|
||||
let resampled = self.resample_capture_to_48k(samples);
|
||||
self.ingest_48k_i16(&resampled);
|
||||
self.resample_scratch = resampled;
|
||||
return;
|
||||
}
|
||||
self.ingest_48k_i16(samples);
|
||||
@@ -215,7 +241,6 @@ impl AndroidCaptureState {
|
||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
let frame = self.pending_10ms;
|
||||
self.process_10ms_capture_frame(&frame);
|
||||
self.encode_complete_20ms_frames();
|
||||
self.pending_10ms_len = 0;
|
||||
}
|
||||
}
|
||||
@@ -225,10 +250,6 @@ impl AndroidCaptureState {
|
||||
return;
|
||||
}
|
||||
|
||||
self.encode_complete_20ms_frames();
|
||||
}
|
||||
|
||||
fn encode_complete_20ms_frames(&mut self) {
|
||||
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]);
|
||||
@@ -237,6 +258,7 @@ impl AndroidCaptureState {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
@@ -264,18 +286,35 @@ impl AndroidCaptureState {
|
||||
}
|
||||
}
|
||||
|
||||
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> usize {
|
||||
let result = crate::capture_resampler::resample_capture_to_48k(
|
||||
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();
|
||||
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> Vec<i16> {
|
||||
if samples.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
result.output_len
|
||||
self.resample_scratch.clear();
|
||||
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) {
|
||||
@@ -354,9 +393,15 @@ impl AndroidCaptureState {
|
||||
self.fallback_warned_backend = None;
|
||||
match vad_backend {
|
||||
crate::VadBackend::SileroOnnx => {
|
||||
self.silero_vad_worker = None;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
let path = crate::vad::silero_model_bundle_path();
|
||||
self.silero_vad_worker =
|
||||
crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&path);
|
||||
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;
|
||||
@@ -375,38 +420,20 @@ impl AndroidCaptureState {
|
||||
speech: true,
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
match crate::vad::callback_vad_worker_policy(
|
||||
voice_activity_mode,
|
||||
vad_backend,
|
||||
self.silero_vad_worker.is_some(),
|
||||
) {
|
||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
||||
let worker = self
|
||||
.silero_vad_worker
|
||||
.as_ref()
|
||||
.expect("policy checked worker");
|
||||
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,
|
||||
)
|
||||
if let Some(worker) = self.silero_vad_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,
|
||||
}
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||
} 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(
|
||||
@@ -414,10 +441,10 @@ impl AndroidCaptureState {
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
},
|
||||
} 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)
|
||||
@@ -445,19 +472,21 @@ impl AndroidCaptureState {
|
||||
return;
|
||||
}
|
||||
|
||||
if crate::capture_accumulator::append_processed_i16_bounded(
|
||||
&mut self.pcm_accum,
|
||||
&frame,
|
||||
self.mic_gain,
|
||||
) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
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
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InputCallback {
|
||||
state: Arc<Mutex<AndroidCaptureState>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
event_tx: BackendEventTx,
|
||||
}
|
||||
|
||||
@@ -469,13 +498,9 @@ impl AudioInputCallback for InputCallback {
|
||||
_stream: &mut dyn AudioInputStreamSafe,
|
||||
frames: &[i16],
|
||||
) -> DataCallbackResult {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| match self.state.try_lock() {
|
||||
Ok(mut s) => 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}");
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
if let Ok(mut s) = self.state.lock() {
|
||||
s.ingest_i16(frames);
|
||||
}
|
||||
}));
|
||||
DataCallbackResult::Continue
|
||||
@@ -497,7 +522,8 @@ impl AudioInputCallback for InputCallback {
|
||||
// writes stereo f32 directly to the Oboe output buffer.
|
||||
|
||||
struct OutputCallback {
|
||||
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
|
||||
handler: AudioHandler<SessionAudioId>,
|
||||
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
event_tx: BackendEventTx,
|
||||
@@ -516,39 +542,47 @@ impl AudioOutputCallback for OutputCallback {
|
||||
frames: &mut [(f32, f32)],
|
||||
) -> DataCallbackResult {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.pcm_consumer.drain_stereo_into_zero_filling(frames);
|
||||
let buf: &mut [f32] =
|
||||
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 muted = self.output_muted.load(Ordering::Relaxed);
|
||||
if muted {
|
||||
for frame in frames.iter_mut() {
|
||||
*frame = (0.0, 0.0);
|
||||
for s in buf.iter_mut() {
|
||||
*s = 0.0;
|
||||
}
|
||||
} else if gain != 1.0 {
|
||||
for (left, right) in frames.iter_mut() {
|
||||
*left *= gain;
|
||||
*right *= gain;
|
||||
for s in buf.iter_mut() {
|
||||
*s *= 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
|
||||
.update_render(dbfs, frames.len() as u32);
|
||||
.update_render(crate::frame::dbfs(buf), frames.len() as u32);
|
||||
|
||||
for (left, right) in frames.iter() {
|
||||
self.pending_render_ref[self.pending_render_ref_len] = (left + right) * 0.5;
|
||||
for chunk in buf.chunks_exact(2) {
|
||||
self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 0.5;
|
||||
self.pending_render_ref_len += 1;
|
||||
if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
self.render_reference.write(&self.pending_render_ref);
|
||||
@@ -580,7 +614,6 @@ impl AudioOutputCallback for OutputCallback {
|
||||
pub struct AndroidVoiceUnit {
|
||||
input: Option<AudioStreamAsync<OboeInput, InputCallback>>,
|
||||
output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,
|
||||
render_producer_shutdown: Arc<AtomicBool>,
|
||||
|
||||
// Recorded achieved values (SDD-112).
|
||||
input_perf: AchievedPerformanceMode,
|
||||
@@ -602,13 +635,11 @@ pub struct AndroidVoiceUnit {
|
||||
|
||||
#[derive(Default)]
|
||||
struct HardwareEffectHandles {
|
||||
aec: Option<AndroidGlobalObject>,
|
||||
ns: Option<AndroidGlobalObject>,
|
||||
agc: Option<AndroidGlobalObject>,
|
||||
aec: Option<jni::objects::GlobalRef>,
|
||||
ns: Option<jni::objects::GlobalRef>,
|
||||
agc: Option<jni::objects::GlobalRef>,
|
||||
}
|
||||
|
||||
type AndroidGlobalObject = jni::refs::Global<jni::objects::JObject<'static>>;
|
||||
|
||||
impl AndroidVoiceUnit {
|
||||
/// Open the input + output streams (SDD-111 + SDD-112) and,
|
||||
/// once a session id is available, attach SDD-113 hardware
|
||||
@@ -675,7 +706,6 @@ impl AndroidVoiceUnit {
|
||||
|
||||
let input_cb = InputCallback {
|
||||
state: capture_state.clone(),
|
||||
audio_processing_stats: audio_processing_stats.clone(),
|
||||
event_tx: event_tx.clone(),
|
||||
};
|
||||
let input_builder = input_builder.set_callback(input_cb);
|
||||
@@ -692,12 +722,7 @@ impl AndroidVoiceUnit {
|
||||
error = ?e,
|
||||
"android: primary input stream open failed; entering fallback ladder"
|
||||
);
|
||||
match Self::open_input_fallback(
|
||||
cfg,
|
||||
&event_tx,
|
||||
capture_state.clone(),
|
||||
audio_processing_stats.clone(),
|
||||
) {
|
||||
match Self::open_input_fallback(cfg, &event_tx, capture_state.clone()) {
|
||||
Ok(s) => Some(s),
|
||||
Err(fallback_err) => {
|
||||
warn!(
|
||||
@@ -764,10 +789,9 @@ impl AndroidVoiceUnit {
|
||||
|
||||
let render_ref_for_output = render_ref_buf.clone();
|
||||
let event_queue = params.event_producer.queue();
|
||||
let render_ring =
|
||||
crate::android_render_ring::AndroidRenderRing::new(ANDROID_RENDER_RING_CAPACITY);
|
||||
let output_cb = OutputCallback {
|
||||
pcm_consumer: render_ring.consumer(),
|
||||
handler: params.handler,
|
||||
event_consumer: AudioEventQueue::consumer(&event_queue),
|
||||
output_gain: params.output_gain.clone(),
|
||||
output_muted: params.output_muted.clone(),
|
||||
event_tx: event_tx.clone(),
|
||||
@@ -789,7 +813,8 @@ impl AndroidVoiceUnit {
|
||||
Self::open_output_fallback(
|
||||
cfg,
|
||||
&event_tx,
|
||||
render_ring.consumer(),
|
||||
AudioHandler::new(),
|
||||
AudioEventQueue::consumer(&event_queue),
|
||||
params.output_gain.clone(),
|
||||
params.output_muted.clone(),
|
||||
audio_processing_stats.clone(),
|
||||
@@ -797,11 +822,6 @@ 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();
|
||||
if output_frames_per_burst > 0 {
|
||||
@@ -958,7 +978,6 @@ impl AndroidVoiceUnit {
|
||||
Ok(Self {
|
||||
input: input_stream,
|
||||
output: Some(output_stream),
|
||||
render_producer_shutdown,
|
||||
input_perf,
|
||||
input_share,
|
||||
output_perf,
|
||||
@@ -976,7 +995,6 @@ impl AndroidVoiceUnit {
|
||||
cfg: &AndroidVoiceStreamConfig,
|
||||
event_tx: &BackendEventTx,
|
||||
capture_state: Arc<Mutex<AndroidCaptureState>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Result<AudioStreamAsync<OboeInput, InputCallback>, BackendError> {
|
||||
// SDD-112 items 6 & 7: explore (preset × sharing) independently
|
||||
// via the pure helpers in `mobile_voice_backend`. Primary
|
||||
@@ -1013,7 +1031,6 @@ impl AndroidVoiceUnit {
|
||||
};
|
||||
let cb = InputCallback {
|
||||
state: capture_state.clone(),
|
||||
audio_processing_stats: audio_processing_stats.clone(),
|
||||
event_tx: event_tx.clone(),
|
||||
};
|
||||
let builder = AudioStreamBuilder::default()
|
||||
@@ -1049,14 +1066,16 @@ impl AndroidVoiceUnit {
|
||||
fn open_output_fallback(
|
||||
cfg: &AndroidVoiceStreamConfig,
|
||||
event_tx: &BackendEventTx,
|
||||
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
|
||||
handler: AudioHandler<SessionAudioId>,
|
||||
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
render_reference: Arc<RenderReferenceBuffer>,
|
||||
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
||||
let cb = OutputCallback {
|
||||
pcm_consumer,
|
||||
handler,
|
||||
event_consumer,
|
||||
output_gain,
|
||||
output_muted,
|
||||
event_tx: event_tx.clone(),
|
||||
@@ -1081,50 +1100,6 @@ impl AndroidVoiceUnit {
|
||||
.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
|
||||
/// registered on the engine's behalf.
|
||||
pub fn event_sender(&self) -> BackendEventTx {
|
||||
@@ -1176,7 +1151,6 @@ impl MobileVoiceAudioBackend for AndroidVoiceUnit {
|
||||
fn close(&mut self) -> Result<(), BackendError> {
|
||||
// SDD-115 reverse order: release hardware effects FIRST,
|
||||
// then close streams.
|
||||
self.render_producer_shutdown.store(true, Ordering::Relaxed);
|
||||
release_hardware_effects(&mut self.hw_effects);
|
||||
self.stop().ok();
|
||||
// Dropping the Option drops the underlying AudioStreamAsync
|
||||
@@ -1234,7 +1208,6 @@ impl Drop for AndroidVoiceUnit {
|
||||
// Wrap in catch_unwind so a panic during Drop cannot unwind
|
||||
// into the JVM (SDD-115 callback safety).
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.render_producer_shutdown.store(true, Ordering::Relaxed);
|
||||
release_hardware_effects(&mut self.hw_effects);
|
||||
// SDD-116: clear the diagnostics slot on Drop too.
|
||||
clear_android_audio_diagnostics();
|
||||
@@ -1314,71 +1287,62 @@ fn attach_hardware_effects_inner(
|
||||
session_id: AudioSessionId,
|
||||
effects: &crate::AudioEffects,
|
||||
) -> 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();
|
||||
if ctx.vm().is_null() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
operation,
|
||||
"android: ndk_context vm null; JNI call skipped"
|
||||
"android: ndk_context vm null; cannot bind hardware effects (software fallback engages)"
|
||||
);
|
||||
return None;
|
||||
return HardwareEffectHandles::default();
|
||||
}
|
||||
|
||||
let jvm = unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) };
|
||||
match jvm.attach_current_thread(|env| Ok::<R, jni::errors::Error>(op(env))) {
|
||||
Ok(value) => Some(value),
|
||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, operation, "android: attach_current_thread failed");
|
||||
None
|
||||
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();
|
||||
if effects.aec {
|
||||
handles.aec = create_effect(
|
||||
&mut env,
|
||||
"android/media/audiofx/AcousticEchoCanceler",
|
||||
session_id,
|
||||
"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
|
||||
/// class before calling `create(int)`. Returns `false` on any JNI
|
||||
/// failure so the caller engages the software fallback.
|
||||
fn effect_is_available(env: &mut jni::Env<'_>, class: &jni::objects::JClass, label: &str) -> bool {
|
||||
match env.call_static_method(
|
||||
class,
|
||||
jni::jni_str!("isAvailable"),
|
||||
jni::jni_sig!("()Z"),
|
||||
&[],
|
||||
) {
|
||||
fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, label: &str) -> bool {
|
||||
match env.call_static_method(class, "isAvailable", "()Z", &[]) {
|
||||
Ok(v) => match v.z() {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
@@ -1396,14 +1360,14 @@ fn effect_is_available(env: &mut jni::Env<'_>, class: &jni::objects::JClass, lab
|
||||
}
|
||||
|
||||
fn create_effect(
|
||||
env: &mut jni::Env<'_>,
|
||||
env: &mut jni::JNIEnv,
|
||||
fqcn: &str,
|
||||
session_id: AudioSessionId,
|
||||
label: &str,
|
||||
) -> Option<AndroidGlobalObject> {
|
||||
) -> Option<jni::objects::GlobalRef> {
|
||||
use jni::objects::JValue;
|
||||
// Class.create(int) -> ClassInstance|null
|
||||
let class = match env.find_class(jni::strings::JNIString::new(fqcn)) {
|
||||
let class = match env.find_class(fqcn) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
|
||||
@@ -1419,18 +1383,10 @@ fn create_effect(
|
||||
);
|
||||
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(
|
||||
&class,
|
||||
jni::jni_str!("create"),
|
||||
create_sig.method_signature(),
|
||||
"create",
|
||||
&format!("(I)L{fqcn};"),
|
||||
&[JValue::Int(session_id)],
|
||||
) {
|
||||
Ok(v) => match v.l() {
|
||||
@@ -1455,8 +1411,8 @@ fn create_effect(
|
||||
// setEnabled(true) -> int (success code)
|
||||
if let Err(e) = env.call_method(
|
||||
&inst,
|
||||
jni::jni_str!("setEnabled"),
|
||||
jni::jni_sig!("(Z)I"),
|
||||
"setEnabled",
|
||||
"(Z)I",
|
||||
&[JValue::Bool(jni::sys::JNI_TRUE)],
|
||||
) {
|
||||
let _ = env.exception_clear();
|
||||
@@ -1492,28 +1448,34 @@ fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) {
|
||||
if aec.is_none() && ns.is_none() && agc.is_none() {
|
||||
return;
|
||||
}
|
||||
let _ = with_android_env("release hardware effects", |env| {
|
||||
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
||||
if let Some(g) = effect {
|
||||
let _ = env.call_method(
|
||||
g.as_obj(),
|
||||
jni::jni_str!("setEnabled"),
|
||||
jni::jni_sig!("(Z)I"),
|
||||
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
||||
);
|
||||
env.exception_clear();
|
||||
let _ = env.call_method(
|
||||
g.as_obj(),
|
||||
jni::jni_str!("release"),
|
||||
jni::jni_sig!("()V"),
|
||||
&[],
|
||||
);
|
||||
env.exception_clear();
|
||||
drop(g);
|
||||
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
|
||||
}
|
||||
let ctx = ndk_context::android_context();
|
||||
if ctx.vm().is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: vm is non-null and owned for process lifetime via JNI_OnLoad.
|
||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut env = match jvm.attach_current_thread() {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
||||
if let Some(g) = effect {
|
||||
let _ = env.call_method(
|
||||
g.as_obj(),
|
||||
"setEnabled",
|
||||
"(Z)I",
|
||||
&[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 --------
|
||||
@@ -1588,7 +1550,7 @@ pub fn chanora_android_stop_voice_service() -> bool {
|
||||
fn call_voice_service_static(method: &str) -> bool {
|
||||
use jni::objects::{JObject, JValue};
|
||||
let ctx = ndk_context::android_context();
|
||||
if ctx.context().is_null() {
|
||||
if ctx.vm().is_null() || ctx.context().is_null() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
method,
|
||||
@@ -1596,41 +1558,54 @@ fn call_voice_service_static(method: &str) -> bool {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
with_android_env("voice foreground service", |env| {
|
||||
// SAFETY: ndk_context::context() is the application Context
|
||||
// jobject; valid global ref for process lifetime.
|
||||
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
||||
let class = match load_app_class(env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
||||
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", 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
|
||||
}
|
||||
// SAFETY: vm/context populated by chanora_bridge::android_init at
|
||||
// JNI_OnLoad + initChanoraContext; both pointers are valid for
|
||||
// the process lifetime.
|
||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, method, "android: JavaVM::from_raw failed");
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let mut env = match jvm.attach_current_thread() {
|
||||
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>(
|
||||
env: &mut jni::Env<'local>,
|
||||
env: &mut jni::JNIEnv<'local>,
|
||||
context_obj: &jni::objects::JObject<'local>,
|
||||
slash_name: &str,
|
||||
) -> Option<jni::objects::JClass<'local>> {
|
||||
match env.find_class(jni::strings::JNIString::new(slash_name)) {
|
||||
match env.find_class(slash_name) {
|
||||
Ok(c) => return Some(c),
|
||||
Err(e) => {
|
||||
let _ = env.exception_clear();
|
||||
@@ -1641,8 +1616,8 @@ fn load_app_class<'local>(
|
||||
let loader = match env
|
||||
.call_method(
|
||||
context_obj,
|
||||
jni::jni_str!("getClassLoader"),
|
||||
jni::jni_sig!("()Ljava/lang/ClassLoader;"),
|
||||
"getClassLoader",
|
||||
"()Ljava/lang/ClassLoader;",
|
||||
&[],
|
||||
)
|
||||
.and_then(|v| v.l())
|
||||
@@ -1667,20 +1642,13 @@ fn load_app_class<'local>(
|
||||
match env
|
||||
.call_method(
|
||||
&loader,
|
||||
jni::jni_str!("loadClass"),
|
||||
jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Class;"),
|
||||
"loadClass",
|
||||
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||
&[jni::objects::JValue::Object(&class_name_obj)],
|
||||
)
|
||||
.and_then(|v| v.l())
|
||||
{
|
||||
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
|
||||
}
|
||||
},
|
||||
Ok(class_obj) => Some(jni::objects::JClass::from(class_obj)),
|
||||
Err(e) => {
|
||||
let _ = env.exception_clear();
|
||||
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed");
|
||||
@@ -1711,7 +1679,7 @@ fn load_app_class<'local>(
|
||||
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange<
|
||||
'local,
|
||||
>(
|
||||
_env: jni::EnvUnowned<'local>,
|
||||
_env: jni::JNIEnv<'local>,
|
||||
_class: jni::objects::JClass<'local>,
|
||||
state: jni::sys::jint,
|
||||
) {
|
||||
@@ -1749,7 +1717,7 @@ pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusContro
|
||||
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange<
|
||||
'local,
|
||||
>(
|
||||
_env: jni::EnvUnowned<'local>,
|
||||
_env: jni::JNIEnv<'local>,
|
||||
_class: jni::objects::JClass<'local>,
|
||||
state: jni::sys::jint,
|
||||
) {
|
||||
@@ -1798,7 +1766,7 @@ pub fn chanora_android_stop_bluetooth_sco() -> bool {
|
||||
fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
||||
use jni::objects::{JObject, JValue};
|
||||
let ctx = ndk_context::android_context();
|
||||
if ctx.context().is_null() {
|
||||
if ctx.vm().is_null() || ctx.context().is_null() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
class = fqcn,
|
||||
@@ -1807,29 +1775,39 @@ fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
with_android_env("static context call", |env| {
|
||||
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
||||
let class = match load_app_class(env, &context_obj, fqcn) {
|
||||
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 jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: JavaVM::from_raw failed");
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let mut env = match jvm.attach_current_thread() {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ const PACKET_QUEUE_CAPACITY: usize = 100;
|
||||
const CONTROL_QUEUE_CAPACITY: usize = 32;
|
||||
|
||||
/// A raw inbound voice packet waiting to be inserted into AudioHandler.
|
||||
#[derive(Debug)]
|
||||
pub struct AudioPacket {
|
||||
/// Client whose TeamSpeak audio packet this belongs to.
|
||||
pub client_id: SessionAudioId,
|
||||
@@ -19,14 +18,12 @@ pub struct AudioPacket {
|
||||
}
|
||||
|
||||
/// Control commands from the main thread to the audio callback.
|
||||
#[derive(Debug)]
|
||||
pub enum AudioCommand {
|
||||
/// Set a client's output volume.
|
||||
SetVolume(SessionAudioId, f32),
|
||||
/// Remove a client's decode queue.
|
||||
// TODO: Wire to client disconnect path; handled in callback but no
|
||||
// producer currently pushes this command.
|
||||
#[allow(dead_code)]
|
||||
RemoveClient(SessionAudioId),
|
||||
}
|
||||
|
||||
@@ -121,64 +118,3 @@ impl AudioEventConsumer {
|
||||
std::iter::from_fn(move || self.queue.control_queue.pop())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn empty_packet(id: u64) -> AudioPacket {
|
||||
let audio = chanora_protocol::AudioData::S2C {
|
||||
codec: chanora_protocol::CodecType::OpusVoice,
|
||||
id: 0x1234,
|
||||
from: 0x5678,
|
||||
data: &[1, 2, 3],
|
||||
};
|
||||
let out = chanora_protocol::OutAudio::new(&audio);
|
||||
AudioPacket {
|
||||
client_id: SessionAudioId(id),
|
||||
data: InAudioBuf::try_new(chanora_protocol::Direction::S2C, out.data().to_vec())
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packet_overflow_increments_drop_counter() {
|
||||
let queue = AudioEventQueue::new();
|
||||
let producer = AudioEventQueue::producer(&queue);
|
||||
|
||||
for i in 0..PACKET_QUEUE_CAPACITY {
|
||||
let packet = empty_packet(i as u64);
|
||||
assert!(producer.push_packet(packet).is_ok());
|
||||
}
|
||||
|
||||
let overflow = empty_packet(999);
|
||||
assert!(producer.push_packet(overflow).is_err());
|
||||
assert_eq!(queue.packets_dropped.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumer_drains_packets_and_controls() {
|
||||
let queue = AudioEventQueue::new();
|
||||
let producer = AudioEventQueue::producer(&queue);
|
||||
let consumer = AudioEventQueue::consumer(&queue);
|
||||
|
||||
producer
|
||||
.push_control(AudioCommand::SetVolume(SessionAudioId(7), 0.5))
|
||||
.unwrap();
|
||||
producer.push_packet(empty_packet(42)).unwrap();
|
||||
|
||||
let packets: Vec<_> = consumer.drain_packets(8).collect();
|
||||
assert_eq!(packets.len(), 1);
|
||||
assert_eq!(packets[0].client_id, SessionAudioId(42));
|
||||
|
||||
let controls: Vec<_> = consumer.drain_controls().collect();
|
||||
assert_eq!(controls.len(), 1);
|
||||
match controls[0] {
|
||||
AudioCommand::SetVolume(id, vol) => {
|
||||
assert_eq!(id, SessionAudioId(7));
|
||||
assert_eq!(vol, 0.5);
|
||||
}
|
||||
AudioCommand::RemoveClient(_) => panic!("unexpected remove-client command"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -314,15 +314,6 @@ mod tests {
|
||||
rec.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ios_raw_debug_wav_does_not_push_from_realtime_callback() {
|
||||
let src = include_str!("ios_raw_unit.rs");
|
||||
assert!(
|
||||
!src.contains("push_raw_mic") && !src.contains("push_processed_mic"),
|
||||
"ios raw callbacks must not call WavDebugRecorder push_*_mic until it has a preallocated handoff"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_header_is_44_bytes() {
|
||||
// Write to a temp file to test the header.
|
||||
|
||||
@@ -981,6 +981,7 @@ impl AudioEngine {
|
||||
|
||||
Ok(Self {
|
||||
transmit_gate,
|
||||
frames_sent,
|
||||
frames_received,
|
||||
output_gain,
|
||||
output_muted,
|
||||
@@ -1084,25 +1085,11 @@ impl AudioEngine {
|
||||
audio_processing_stats: audio_processing_stats.clone(),
|
||||
};
|
||||
let mut android_voice_unit =
|
||||
match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params) {
|
||||
Ok(unit) => unit,
|
||||
Err(e) => {
|
||||
Self::rollback_android_startup_resources(&mut audio_mode_stack);
|
||||
return Err(AudioError::Backend(format!(
|
||||
"android: failed to open Oboe voice unit: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params).map_err(|e| {
|
||||
AudioError::Backend(format!("android: failed to open Oboe voice unit: {e}"))
|
||||
})?;
|
||||
|
||||
if let Err(e) = android_voice_unit.start() {
|
||||
if let Err(close_err) = android_voice_unit.close() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %close_err,
|
||||
"android: AndroidVoiceUnit::close failed during startup rollback"
|
||||
);
|
||||
}
|
||||
Self::rollback_android_startup_resources(&mut audio_mode_stack);
|
||||
return Err(AudioError::Backend(format!(
|
||||
"android: failed to start Oboe voice unit: {e}"
|
||||
)));
|
||||
@@ -1259,7 +1246,6 @@ impl AudioEngine {
|
||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||
Arc::new(Mutex::new(AudioHandler::new()));
|
||||
let voice_out_tx_for_backend = voice_out_tx.clone();
|
||||
@@ -1292,8 +1278,8 @@ impl AudioEngine {
|
||||
// VPIO render callback (commit 4) finds decoded frames
|
||||
// waiting.
|
||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
let frames_received_for_task = frames_received.clone();
|
||||
let handler_for_task = audio_handler.clone();
|
||||
let frames_received_for_task = frames_received.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -1306,15 +1292,10 @@ impl AudioEngine {
|
||||
Some(v) => {
|
||||
let id = SessionAudioId(v.from_client);
|
||||
let mut h = handler_for_task.lock().unwrap();
|
||||
let res = h.handle_packet(id, v.packet);
|
||||
drop(h);
|
||||
match res {
|
||||
Ok(_) => {
|
||||
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||
}
|
||||
if let Err(e) = h.handle_packet(id, v.packet) {
|
||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||
} else {
|
||||
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
@@ -1339,43 +1320,6 @@ impl AudioEngine {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn rollback_android_startup_resources(audio_mode_stack: &mut crate::mode_stack::ModeStack) {
|
||||
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
|
||||
crate::android_voice_unit::chanora_android_abandon_audio_focus();
|
||||
match release_android_audio_mode_for_startup_rollback(audio_mode_stack) {
|
||||
crate::mode_stack::ModeRelease::LastRelease { prior } => {
|
||||
match android_set_audio_mode(prior) {
|
||||
Ok(()) => info!(
|
||||
target: "chanora_audio",
|
||||
restored_mode = prior,
|
||||
"android: AudioManager mode restored during startup rollback"
|
||||
),
|
||||
Err(e) => warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
restored_mode = prior,
|
||||
"android: failed to restore AudioManager mode during startup rollback"
|
||||
),
|
||||
}
|
||||
}
|
||||
crate::mode_stack::ModeRelease::StillHeld => {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
"android: audio mode still held during startup rollback"
|
||||
);
|
||||
}
|
||||
crate::mode_stack::ModeRelease::AlreadyReleased => {}
|
||||
}
|
||||
|
||||
if crate::android_voice_unit::chanora_android_stop_voice_service() {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
"android: voice foreground service stopped during startup rollback"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the engine. Idempotent.
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
@@ -1770,33 +1714,6 @@ impl Drop for AudioEngine {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "android"))]
|
||||
fn release_android_audio_mode_for_startup_rollback(
|
||||
audio_mode_stack: &mut crate::mode_stack::ModeStack,
|
||||
) -> crate::mode_stack::ModeRelease {
|
||||
audio_mode_stack.release()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn android_startup_rollback_releases_acquired_mode_snapshot() {
|
||||
let mut stack = crate::mode_stack::ModeStack::new();
|
||||
let _ = stack.acquire(7);
|
||||
|
||||
let release = release_android_audio_mode_for_startup_rollback(&mut stack);
|
||||
|
||||
assert_eq!(
|
||||
release,
|
||||
crate::mode_stack::ModeRelease::LastRelease { prior: 7 }
|
||||
);
|
||||
assert_eq!(stack.refcount(), 0);
|
||||
assert_eq!(stack.snapshot(), None);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Capture pipeline ----------
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
@@ -1839,12 +1756,9 @@ fn try_open_capture(
|
||||
in_sample_rate,
|
||||
in_channels,
|
||||
mic_gain,
|
||||
crate::opus_voice::start_out_packet_worker(
|
||||
voice_out_tx,
|
||||
frames_sent.clone(),
|
||||
"cpal-capture",
|
||||
)?,
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
audio_processing_stats,
|
||||
)));
|
||||
|
||||
@@ -1879,10 +1793,11 @@ struct CaptureState {
|
||||
/// Linux ALSA defaults).
|
||||
resample_last: f32,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
/// The PTT transmission gate. Read once per outbound frame; the
|
||||
/// CaptureState never mutates this flag.
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
/// Pre-allocated mono downmix buffer. Resized in-place each
|
||||
/// callback; `clear()` retains capacity. SDD-094 realtime-thread
|
||||
/// invariant: this avoids the heap allocation that the prior fix
|
||||
@@ -1897,26 +1812,8 @@ struct CaptureState {
|
||||
/// after warmup. Same precedent as `mono_scratch` above.
|
||||
frame_scratch: Vec<f32>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
/// Time-based decimation for the level meter. The cpal callback
|
||||
/// cadence depends on platform (256 frames at 48 kHz ≈ 187 Hz,
|
||||
/// 512 frames ≈ 94 Hz, 1024 frames ≈ 47 Hz) and can change at
|
||||
/// runtime on device or sample-rate switch. The bridge consumer
|
||||
/// (`input_level_stream`) only reads at ~30 Hz, so computing
|
||||
/// `sqrt()` + `log10()` on every callback wastes real-time budget
|
||||
/// and caused buffer underruns on macOS CoreAudio with small
|
||||
/// buffer sizes. We only emit a new dBFS sample after at least
|
||||
/// [LEVEL_METER_INTERVAL] has elapsed since the previous emit,
|
||||
/// which is platform-cadence-independent.
|
||||
last_level_emit: std::time::Instant,
|
||||
}
|
||||
|
||||
/// Minimum interval between input-level dBFS samples sent to the
|
||||
/// bridge. Matches the consumer rate (`input_level_stream` at ~30 Hz).
|
||||
/// Time-based gating is robust to cpal buffer-size and sample-rate
|
||||
/// changes that a fixed callback-count would not be.
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
const LEVEL_METER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(33);
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
impl CaptureState {
|
||||
fn new(
|
||||
@@ -1924,8 +1821,9 @@ impl CaptureState {
|
||||
in_sample_rate: u32,
|
||||
in_channels: usize,
|
||||
mic_gain: f32,
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -1939,13 +1837,10 @@ impl CaptureState {
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
mono_scratch: Vec::with_capacity(4096),
|
||||
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
|
||||
audio_processing_stats,
|
||||
// Start in the past so the first ingest emits immediately.
|
||||
last_level_emit: std::time::Instant::now()
|
||||
.checked_sub(LEVEL_METER_INTERVAL)
|
||||
.unwrap_or_else(std::time::Instant::now),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1965,15 +1860,10 @@ impl CaptureState {
|
||||
self.mono_scratch.push(sum / frame.len() as f32);
|
||||
}
|
||||
|
||||
// Level meter: pay sqrt() + log10() only when at least
|
||||
// LEVEL_METER_INTERVAL has elapsed, regardless of the
|
||||
// platform's cpal callback cadence.
|
||||
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));
|
||||
}
|
||||
// Measure dBFS from pre-gain samples so the level meter
|
||||
// reflects the raw mic input, not the amplified signal.
|
||||
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 {
|
||||
@@ -2035,6 +1925,7 @@ impl CaptureState {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
@@ -2448,13 +2339,6 @@ impl std::fmt::Display for AudioModeError {
|
||||
#[cfg(target_os = "android")]
|
||||
impl std::error::Error for AudioModeError {}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl From<jni::errors::Error> for AudioModeError {
|
||||
fn from(value: jni::errors::Error) -> Self {
|
||||
Self::JniAttachFailed(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// JNI helper shared by `android_get_audio_mode` and
|
||||
/// `android_set_audio_mode`: attach to the current thread and return
|
||||
/// the `AudioManager` jobject. Centralised so SDD-108's two platform
|
||||
@@ -2462,10 +2346,7 @@ impl From<jni::errors::Error> for AudioModeError {
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_audio_manager_call<F, R>(op: F) -> Result<R, AudioModeError>
|
||||
where
|
||||
F: for<'local> FnOnce(
|
||||
&mut jni::Env<'local>,
|
||||
&jni::objects::JObject<'local>,
|
||||
) -> Result<R, AudioModeError>
|
||||
F: FnOnce(&mut jni::JNIEnv, &jni::objects::JObject) -> Result<R, AudioModeError>
|
||||
+ std::panic::UnwindSafe,
|
||||
{
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
@@ -2482,40 +2363,42 @@ where
|
||||
// at a live JavaVM* set by our bridge_init JNI hook. The
|
||||
// unsafe block contains only the cast required by
|
||||
// `JavaVM::from_raw`.
|
||||
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) };
|
||||
jvm.attach_current_thread(|env| -> Result<R, AudioModeError> {
|
||||
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
||||
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) }
|
||||
.map_err(|e| AudioModeError::JniAttachFailed(format!("jvm from_raw: {e}")))?;
|
||||
let mut env = jvm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| AudioModeError::JniAttachFailed(format!("attach: {e}")))?;
|
||||
|
||||
let service_name: JString =
|
||||
env.new_string("audio")
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "new_string",
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
let service_name_obj = JObject::from(service_name);
|
||||
let audio_manager = env
|
||||
.call_method(
|
||||
&context_obj,
|
||||
jni::jni_str!("getSystemService"),
|
||||
jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Object;"),
|
||||
&[JValue::Object(&service_name_obj)],
|
||||
)
|
||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||
|
||||
let service_name: JString =
|
||||
env.new_string("audio")
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getSystemService",
|
||||
method: "new_string",
|
||||
detail: e.to_string(),
|
||||
})?
|
||||
.l()
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getSystemService",
|
||||
detail: format!("obj cast: {e}"),
|
||||
})?;
|
||||
if audio_manager.is_null() {
|
||||
return Err(AudioModeError::Other(
|
||||
"AudioManager service is null".to_string(),
|
||||
));
|
||||
}
|
||||
op(env, &audio_manager)
|
||||
})
|
||||
let audio_manager = env
|
||||
.call_method(
|
||||
&context_obj,
|
||||
"getSystemService",
|
||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
&[JValue::Object(&service_name.into())],
|
||||
)
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getSystemService",
|
||||
detail: e.to_string(),
|
||||
})?
|
||||
.l()
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getSystemService",
|
||||
detail: format!("obj cast: {e}"),
|
||||
})?;
|
||||
if audio_manager.is_null() {
|
||||
return Err(AudioModeError::Other(
|
||||
"AudioManager service is null".to_string(),
|
||||
));
|
||||
}
|
||||
op(&mut env, &audio_manager)
|
||||
});
|
||||
match result {
|
||||
Ok(inner) => inner,
|
||||
@@ -2534,21 +2417,16 @@ where
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
|
||||
android_audio_manager_call(|env, audio_manager| {
|
||||
env.call_method(
|
||||
audio_manager,
|
||||
jni::jni_str!("getMode"),
|
||||
jni::jni_sig!("()I"),
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getMode",
|
||||
detail: e.to_string(),
|
||||
})?
|
||||
.i()
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getMode",
|
||||
detail: format!("int cast: {e}"),
|
||||
})
|
||||
env.call_method(audio_manager, "getMode", "()I", &[])
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getMode",
|
||||
detail: e.to_string(),
|
||||
})?
|
||||
.i()
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getMode",
|
||||
detail: format!("int cast: {e}"),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2562,16 +2440,11 @@ pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
|
||||
pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
|
||||
use jni::objects::JValue;
|
||||
android_audio_manager_call(move |env, audio_manager| {
|
||||
env.call_method(
|
||||
audio_manager,
|
||||
jni::jni_str!("setMode"),
|
||||
jni::jni_sig!("(I)V"),
|
||||
&[JValue::Int(mode)],
|
||||
)
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "setMode",
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
env.call_method(audio_manager, "setMode", "(I)V", &[JValue::Int(mode)])
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "setMode",
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,11 +42,13 @@ mod inner {
|
||||
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;
|
||||
|
||||
@@ -61,10 +63,43 @@ mod inner {
|
||||
/// 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.
|
||||
type RenderReferenceBuffer = crate::render_reference::RenderReferenceBuffer<480, 4>;
|
||||
type RenderReferenceFrameAccumulator =
|
||||
crate::render_reference::RenderReferenceFrameAccumulator<480>;
|
||||
const RAW_RENDER_SCRATCH_FRAMES: usize = 1024;
|
||||
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 //
|
||||
@@ -74,9 +109,10 @@ mod inner {
|
||||
encoder: OpusEncoder,
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
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,
|
||||
@@ -92,6 +128,7 @@ mod inner {
|
||||
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 {
|
||||
@@ -109,13 +146,10 @@ mod inner {
|
||||
encoder,
|
||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||
params.voice_out_tx.clone(),
|
||||
params.frames_sent.clone(),
|
||||
"ios-raw",
|
||||
)?,
|
||||
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(),
|
||||
@@ -132,6 +166,7 @@ mod inner {
|
||||
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: 0,
|
||||
fallback_warned_backend: None,
|
||||
wav_recorder: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,7 +204,6 @@ mod inner {
|
||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
let frame = self.pending_10ms;
|
||||
self.process_10ms_capture_frame(&frame);
|
||||
self.encode_complete_20ms_frames();
|
||||
self.pending_10ms_len = 0;
|
||||
}
|
||||
}
|
||||
@@ -179,10 +213,7 @@ mod inner {
|
||||
return;
|
||||
}
|
||||
|
||||
self.encode_complete_20ms_frames();
|
||||
}
|
||||
|
||||
fn encode_complete_20ms_frames(&mut self) {
|
||||
// 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]);
|
||||
@@ -192,6 +223,7 @@ mod inner {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
@@ -221,18 +253,20 @@ mod inner {
|
||||
}
|
||||
let input_dbfs = crate::frame::dbfs(&frame);
|
||||
|
||||
// Debug WAV mic taps are intentionally unavailable on iOS raw
|
||||
// realtime callbacks until WavDebugRecorder supports a
|
||||
// preallocated handoff path; the current recorder push path
|
||||
// allocates per 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);
|
||||
|
||||
// Processed-mic debug WAV capture is disabled for the same
|
||||
// realtime allocation reason as the raw-mic tap above.
|
||||
// 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
|
||||
@@ -266,9 +300,14 @@ mod inner {
|
||||
self.current_vad_backend = vad_backend;
|
||||
self.fallback_warned_backend = None;
|
||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
self.silero_coreml_worker = None;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
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);
|
||||
@@ -286,38 +325,20 @@ mod inner {
|
||||
speech: true,
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
match crate::vad::callback_vad_worker_policy(
|
||||
voice_activity_mode,
|
||||
vad_backend,
|
||||
self.silero_coreml_worker.is_some(),
|
||||
) {
|
||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
||||
let worker = self
|
||||
.silero_coreml_worker
|
||||
.as_ref()
|
||||
.expect("policy checked worker");
|
||||
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,
|
||||
)
|
||||
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,
|
||||
}
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||
} 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(
|
||||
@@ -325,10 +346,13 @@ mod inner {
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
},
|
||||
} 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)
|
||||
@@ -354,12 +378,15 @@ mod inner {
|
||||
return;
|
||||
}
|
||||
|
||||
if crate::capture_accumulator::append_processed_i16_bounded(
|
||||
&mut self.pcm_accum,
|
||||
&frame,
|
||||
self.mic_gain,
|
||||
) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
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
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,9 +446,7 @@ mod inner {
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
|
||||
|
||||
let mut scratch = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES * 2];
|
||||
let mut mono = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES];
|
||||
let mut render_ref_accum = RenderReferenceFrameAccumulator::new();
|
||||
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();
|
||||
@@ -430,10 +455,9 @@ mod inner {
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let out = args.data.buffer;
|
||||
let n = out.len();
|
||||
let process_n = n.min(RAW_RENDER_SCRATCH_FRAMES);
|
||||
let stereo_n = process_n * 2;
|
||||
if n > RAW_RENDER_SCRATCH_FRAMES {
|
||||
stats_render.increment_callback_xrun();
|
||||
let stereo_n = n * 2;
|
||||
if scratch.len() < stereo_n {
|
||||
scratch.resize(stereo_n, 0.0);
|
||||
}
|
||||
scratch[..stereo_n].fill(0.0);
|
||||
|
||||
@@ -451,26 +475,22 @@ mod inner {
|
||||
}
|
||||
|
||||
// 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 mono[..process_n],
|
||||
&mut ref_frame[..mono_n],
|
||||
);
|
||||
render_ref_accum.push_mono_samples(&mono[..process_n], |frame| {
|
||||
render_ref_buf.write(frame);
|
||||
});
|
||||
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_interleaved_i16(
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
||||
&scratch[..stereo_n],
|
||||
&mut out[..process_n],
|
||||
1,
|
||||
out,
|
||||
gain,
|
||||
muted,
|
||||
);
|
||||
if process_n < n {
|
||||
out[process_n..].fill(0);
|
||||
}
|
||||
if mix_stats.clipped_samples > 0 {
|
||||
stats_render.add_clipped_samples(mix_stats.clipped_samples);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
//! * AVAudioSession category / mode configuration — Swift owns the
|
||||
//! session (it must be set up before Flutter loads).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use audiopus::coder::Encoder as OpusEncoder;
|
||||
@@ -74,11 +74,12 @@ 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 crossbeam::queue::ArrayQueue;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::mobile_voice_backend::VoiceAudioParams;
|
||||
use crate::AudioError;
|
||||
use chanora_protocol::OutPacket;
|
||||
|
||||
/// Sample rate every layer above us assumes. Matches the Opus
|
||||
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
|
||||
@@ -103,15 +104,6 @@ const INPUT_BUS: Element = Element::Input;
|
||||
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
|
||||
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
|
||||
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
|
||||
/// downmix or resample needed — VPIO's hardware-side mix-down
|
||||
@@ -139,9 +131,10 @@ struct IosCaptureState {
|
||||
/// jitter without reallocating.
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
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,
|
||||
@@ -160,6 +153,7 @@ struct IosCaptureState {
|
||||
pre_roll_count: usize,
|
||||
pre_roll_flushed: bool,
|
||||
capture_frame_seq: u64,
|
||||
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
||||
}
|
||||
|
||||
impl IosCaptureState {
|
||||
@@ -167,20 +161,20 @@ impl IosCaptureState {
|
||||
/// Encoder configuration is the same as cpal-side
|
||||
/// `try_open_capture` (engine.rs) so audio quality is platform-
|
||||
/// neutral.
|
||||
fn new(params: &VoiceAudioParams) -> Result<Self, AudioError> {
|
||||
fn new(
|
||||
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")?;
|
||||
|
||||
Ok(Self {
|
||||
encoder,
|
||||
pcm_accum: Vec::with_capacity(CAPTURE_ACCUM_CAPACITY_SAMPLES),
|
||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||
params.voice_out_tx.clone(),
|
||||
params.frames_sent.clone(),
|
||||
"ios-vpio",
|
||||
)?,
|
||||
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(),
|
||||
@@ -198,6 +192,7 @@ impl IosCaptureState {
|
||||
pre_roll_count: 0,
|
||||
pre_roll_flushed: false,
|
||||
capture_frame_seq: 0,
|
||||
wav_recorder,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -271,6 +266,7 @@ impl IosCaptureState {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
@@ -301,9 +297,25 @@ impl IosCaptureState {
|
||||
}
|
||||
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
|
||||
// last-known values if the lock is contended — safe to miss one frame).
|
||||
let (run_ns, run_agc, run_hpf, vad_backend, vad_hangover, debug_wav_dump_enabled) = self
|
||||
let (
|
||||
run_ns,
|
||||
run_agc,
|
||||
run_hpf,
|
||||
vad_backend,
|
||||
vad_hangover,
|
||||
debug_wav_dump_enabled,
|
||||
route,
|
||||
processing_backend,
|
||||
) = self
|
||||
.audio_processing_config
|
||||
.try_lock()
|
||||
.map(|cfg| {
|
||||
@@ -319,6 +331,8 @@ impl IosCaptureState {
|
||||
cfg.vad_backend,
|
||||
cfg.vad_hangover_ms,
|
||||
cfg.debug_wav_dump_enabled,
|
||||
cfg.route,
|
||||
cfg.processing_backend,
|
||||
)
|
||||
})
|
||||
.unwrap_or((
|
||||
@@ -328,13 +342,10 @@ impl IosCaptureState {
|
||||
crate::VadBackend::WebrtcVad,
|
||||
crate::voice_activity::VAD_HANGOVER_MS,
|
||||
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
|
||||
.voice_activity_selector
|
||||
.as_ref()
|
||||
@@ -347,13 +358,32 @@ impl IosCaptureState {
|
||||
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 {
|
||||
self.current_vad_backend = vad_backend;
|
||||
self.fallback_warned_backend = None;
|
||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
self.silero_coreml_worker = None;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
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);
|
||||
@@ -406,38 +436,20 @@ impl IosCaptureState {
|
||||
speech: true,
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
match crate::vad::callback_vad_worker_policy(
|
||||
voice_activity_mode,
|
||||
vad_backend,
|
||||
self.silero_coreml_worker.is_some(),
|
||||
) {
|
||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
||||
let worker = self
|
||||
.silero_coreml_worker
|
||||
.as_ref()
|
||||
.expect("policy checked worker");
|
||||
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(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
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,
|
||||
}
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
@@ -445,10 +457,10 @@ impl IosCaptureState {
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
},
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
}
|
||||
} else {
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
@@ -471,6 +483,13 @@ impl IosCaptureState {
|
||||
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.
|
||||
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
|
||||
@@ -508,13 +527,7 @@ impl IosCaptureState {
|
||||
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
|
||||
for i in 0..pre_roll_to_emit {
|
||||
let idx = (oldest + i) % PRE_ROLL_FRAMES;
|
||||
if crate::capture_accumulator::append_i16_bounded(
|
||||
&mut self.pcm_accum,
|
||||
&self.pre_roll_buf[idx],
|
||||
) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
break;
|
||||
}
|
||||
self.pcm_accum.extend_from_slice(&self.pre_roll_buf[idx]);
|
||||
}
|
||||
} else if !transmit_active {
|
||||
// Gate closed — reset the flush flag so pre-roll fires again
|
||||
@@ -526,9 +539,7 @@ impl IosCaptureState {
|
||||
return;
|
||||
}
|
||||
|
||||
if crate::capture_accumulator::append_i16_bounded(&mut self.pcm_accum, &pcm_frame) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
}
|
||||
self.pcm_accum.extend_from_slice(&pcm_frame);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,13 +550,6 @@ pub struct IosVoiceUnit {
|
||||
// wrapper's own Drop calls AudioComponentInstanceDispose
|
||||
// after stop returns.
|
||||
unit: Option<AudioUnit>,
|
||||
// macOS-only: producer task (spawned in start_macos) polls
|
||||
// this on every 20 ms tick and exits when set. Without it
|
||||
// the tokio task captures `Arc<Mutex<AudioHandler>>` +
|
||||
// `Arc<ArrayQueue<f32>>` and runs forever, leaking on every
|
||||
// engine stop/restart cycle.
|
||||
#[cfg(target_os = "macos")]
|
||||
producer_shutdown: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl IosVoiceUnit {
|
||||
@@ -647,44 +651,6 @@ impl IosVoiceUnit {
|
||||
)
|
||||
.map_err(|e| AudioError::Backend(format!("vpio enable input I/O: {e}")))?;
|
||||
|
||||
// VPIO defaults to maximum "duck others" — when our voice plays,
|
||||
// every other app's audio (Music, Safari, Discord, ...) is heavily
|
||||
// attenuated. That is correct for a phone call but wrong for a
|
||||
// chat client running alongside music or game audio. There is no
|
||||
// public "off" switch; the lowest publicly-exposed level is Min
|
||||
// and disabling Advanced Ducking turns off the voice-activity-
|
||||
// driven dynamic ducking. This matches what Hume, Moonshine, and
|
||||
// similar open-source VoIP/voice apps configure.
|
||||
// Apple ref: kAUVoiceIOProperty_OtherAudioDuckingConfiguration
|
||||
// Property ID 2108, Global scope, Output element (= 0).
|
||||
// Struct layout matches AUVoiceIOOtherAudioDuckingConfiguration
|
||||
// from <AudioToolbox/AUVoiceIOOtherAudioDuckingConfiguration.h>:
|
||||
// Boolean (u8) mEnableAdvancedDucking + AUVoiceIOOtherAudio-
|
||||
// DuckingLevel (u32) mDuckingLevel, #[repr(C)] yields 8 bytes
|
||||
// with the 3-byte natural alignment pad before the u32.
|
||||
#[repr(C)]
|
||||
struct AuVoiceIoOtherAudioDuckingConfiguration {
|
||||
m_enable_advanced_ducking: u8,
|
||||
m_ducking_level: u32,
|
||||
}
|
||||
const K_AU_VOICE_IO_PROPERTY_OTHER_AUDIO_DUCKING_CONFIGURATION: u32 = 2108;
|
||||
const K_AU_VOICE_IO_OTHER_AUDIO_DUCKING_LEVEL_MIN: u32 = 10;
|
||||
let ducking_config = AuVoiceIoOtherAudioDuckingConfiguration {
|
||||
m_enable_advanced_ducking: 0,
|
||||
m_ducking_level: K_AU_VOICE_IO_OTHER_AUDIO_DUCKING_LEVEL_MIN,
|
||||
};
|
||||
// Apple introduced this property in iOS 17 / macOS 14. On older
|
||||
// OS versions VPIO returns kAudioUnitErr_InvalidProperty (-10879)
|
||||
// — log it but never fail VPIO startup over a ducking knob.
|
||||
if let Err(e) = unit.set_property(
|
||||
K_AU_VOICE_IO_PROPERTY_OTHER_AUDIO_DUCKING_CONFIGURATION,
|
||||
Scope::Global,
|
||||
Element::Output,
|
||||
Some(&ducking_config),
|
||||
) {
|
||||
tracing::debug!("vpio set OtherAudioDuckingConfiguration failed (older OS?): {e}");
|
||||
}
|
||||
|
||||
// Note: we keep VPIO's voice processing chain ENABLED
|
||||
// (AEC + AGC + NS on the mic path) because it gives us
|
||||
// clean capture for free. The historical playback
|
||||
@@ -734,7 +700,18 @@ impl IosVoiceUnit {
|
||||
// scratch are owned by the closure — no Mutex needed
|
||||
// because the input callback is the sole writer/reader on
|
||||
// the audio thread.
|
||||
let mut capture_state = IosCaptureState::new(¶ms)?;
|
||||
let wav_recorder = Arc::new(Mutex::new({
|
||||
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>>| {
|
||||
// VPIO with our pinned stream format delivers
|
||||
@@ -760,14 +737,13 @@ impl IosVoiceUnit {
|
||||
// buffer + mix. Same primitive cpal + SDL output
|
||||
// paths use; this is the platform-neutral playback
|
||||
// contract from `tsclientlib::audio::AudioHandler`.
|
||||
// 2. Downmix to mono i16 with master gain, then copy that
|
||||
// mono sample across every output channel the callback
|
||||
// exposes. We still request mono Int16 from VPIO, but
|
||||
// the callback must respect the actual channel count it
|
||||
// receives. The handler itself produces stereo f32, so
|
||||
// we average L+R rather than dropping R — the cpal-side
|
||||
// mono-output path made the same mistake briefly
|
||||
// (commit 6a4dbad / fix) and lost half the spatial mix.
|
||||
// 2. Downmix to i16 mono with master gain. VPIO expects
|
||||
// mono int16 (the stream format we pinned above);
|
||||
// the handler produces stereo f32. We average L+R
|
||||
// to a single mono channel rather than dropping R —
|
||||
// the cpal-side mono-output path made the same
|
||||
// mistake briefly (commit 6a4dbad / fix) and lost
|
||||
// half the spatial mix.
|
||||
// 3. Local-mute zeroes the output but STILL drains
|
||||
// AudioHandler in step 1 so its jitter buffer
|
||||
// doesn't grow unbounded while muted. This is the
|
||||
@@ -796,270 +772,126 @@ impl IosVoiceUnit {
|
||||
//
|
||||
// Revert to direct call: render callback locks
|
||||
// AudioHandler, asks for `num_frames` stereo frames, and
|
||||
// immediately downmixes to mono i16 replicated across the
|
||||
// callback's actual output channels. Same as Linux/SDL,
|
||||
// just stereo-f32 -> interleaved-i16 converted at the
|
||||
// boundary.
|
||||
// macOS cadence-fix (commit-f):
|
||||
//
|
||||
// The render callback is the wrong place to call AudioHandler::fill_buffer.
|
||||
// macOS VPIO invokes us with `num_frames=512` (= 10.67 ms @ 48 kHz) which
|
||||
// is NOT a multiple of the 20 ms Opus frame size that AudioHandler expects
|
||||
// inside fill_buffer. The mismatch (a) leaves fill_buffer unable to
|
||||
// satisfy the request on most calls and (b) advances MAX_PACKET_LOSSES,
|
||||
// which removes the talker and starts PLC silence/decay.
|
||||
//
|
||||
// Converged fix across Mumble (Speex jitter buffer + decoded PCM FIFO),
|
||||
// WebRTC NetEQ (adaptive 80ms prebuffer + sync buffer), Songbird (Fill→
|
||||
// Drain playout buffer), cpal/rodio (SPSC ring + zero-fill on underrun),
|
||||
// tsclientlib's own SDL example, and the upstream tsclientlib::audio
|
||||
// contract: separate ingress quantum (20 ms decoded PCM = 1920 stereo
|
||||
// f32) from egress quantum (whatever VPIO asks for), connected by a
|
||||
// lock-free ring of decoded PCM.
|
||||
//
|
||||
// Layout:
|
||||
// * macOS: spawn a tokio producer task paced at 20 ms; each tick
|
||||
// try-locks AudioHandler, calls fill_buffer(1920), pushes 1920
|
||||
// f32 into the ring. The render callback only pops — no lock,
|
||||
// no Opus decode, no allocator on the audio thread. 60 ms
|
||||
// prebuffer (3 × 20 ms) is held before the callback starts
|
||||
// draining, matching Mumble's playout margin and WebRTC's
|
||||
// kStartDelayMs order of magnitude. Crossbeam ArrayQueue
|
||||
// is used because we already depend on crossbeam-queue.
|
||||
// * iOS: keep the existing direct fill_buffer path — VPIO on iOS
|
||||
// requests 480-frame slices that ARE 20 ms aligned so the
|
||||
// cadence mismatch does not arise there.
|
||||
#[cfg(target_os = "macos")]
|
||||
let producer_shutdown = Arc::new(AtomicBool::new(false));
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// 100 ms capacity = 9600 stereo f32. Sized so that the 60 ms
|
||||
// prebuffer plus a few jitter spikes fit without forcing the
|
||||
// producer to drop frames. crossbeam ArrayQueue is fixed-cap
|
||||
// and lock-free SPSC-ish (MPMC but wait-free per end); for
|
||||
// single producer + single consumer it's effectively SPSC.
|
||||
const RING_CAPACITY: usize = 12000;
|
||||
const PULL_SAMPLES: usize = 1920; // one 20 ms Opus frame, stereo
|
||||
const PREBUFFER_SAMPLES: usize = 9600; // 100 ms @ 48 kHz stereo
|
||||
|
||||
let pcm_ring: Arc<ArrayQueue<f32>> = Arc::new(ArrayQueue::new(RING_CAPACITY));
|
||||
let pcm_ring_producer = pcm_ring.clone();
|
||||
let pcm_ring_consumer = pcm_ring.clone();
|
||||
let handler_for_producer = params.handler.clone();
|
||||
let output_gain_for_render = params.output_gain.clone();
|
||||
let output_muted_for_render = params.output_muted.clone();
|
||||
|
||||
let producer_shutdown_for_task = producer_shutdown.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut pull_scratch: Vec<f32> = vec![0.0; 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 producer_shutdown_for_task.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
match handler_for_producer.try_lock() {
|
||||
Ok(mut h) => {
|
||||
pull_scratch.fill(0.0);
|
||||
let _ = h.fill_buffer(&mut pull_scratch[..]);
|
||||
for &s in &pull_scratch {
|
||||
pcm_ring_producer.force_push(s);
|
||||
}
|
||||
}
|
||||
Err(std::sync::TryLockError::WouldBlock) => {}
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
warn!(target: "chanora_audio",
|
||||
"producer: AudioHandler mutex poisoned: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// immediately downmixes to i16 mono into the output
|
||||
// buffer. Same as Linux/SDL, just stereo-f32 -> mono-i16
|
||||
// converted at the boundary.
|
||||
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(2048);
|
||||
let handler_for_render = params.handler.clone();
|
||||
let output_gain_for_render = params.output_gain.clone();
|
||||
let output_muted_for_render = params.output_muted.clone();
|
||||
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
||||
let wav_recorder_for_render = wav_recorder.clone();
|
||||
// Diagnostic counters (sampled every 100 callbacks ~= 2 s).
|
||||
let mut cb_count: u64 = 0;
|
||||
let mut last_num_frames: usize = 0;
|
||||
let mut num_frames_changes: u32 = 0;
|
||||
let mut callbacks_with_audio: u64 = 0;
|
||||
let mut callbacks_with_silence: u64 = 0;
|
||||
let mut render_ref_accum = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut render_ref_len: usize = 0;
|
||||
let mut render_recorder_active = false;
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let out: &mut [i16] = args.data.buffer;
|
||||
let num_frames = out.len();
|
||||
// AudioHandler produces 48 kHz stereo f32 (= num_frames * 2 floats).
|
||||
let needed = num_frames * 2;
|
||||
if scratch_stereo.len() < needed {
|
||||
scratch_stereo.resize(needed, 0.0);
|
||||
}
|
||||
// Zero the live slice. AudioHandler::fill_buffer is
|
||||
// additive (does NOT clear); residual values from
|
||||
// earlier callbacks (when scratch was bigger) would
|
||||
// leak through otherwise.
|
||||
scratch_stereo[..needed].fill(0.0);
|
||||
// Non-blocking fill on the realtime callback thread.
|
||||
// If the inbound forwarder currently owns this mutex,
|
||||
// emit this period as silence instead of blocking and
|
||||
// risking an AudioUnit underrun pop/click.
|
||||
match handler_for_render.try_lock() {
|
||||
Ok(mut h) => {
|
||||
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
|
||||
}
|
||||
});
|
||||
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let render_callback::Args {
|
||||
data, num_frames, ..
|
||||
} = args;
|
||||
let out: &mut [i16] = data.buffer;
|
||||
let out_channels = data.channels;
|
||||
let needed = num_frames * out_channels;
|
||||
|
||||
if pcm_ring_consumer.len() < PREBUFFER_SAMPLES {
|
||||
for sample in &mut out[..needed] {
|
||||
*sample = 0;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Pop L,R as a pair per frame. The pairing is
|
||||
// load-bearing — `written += 2` half-buffer bug.
|
||||
let mut written_frames: usize = 0;
|
||||
while written_frames < num_frames {
|
||||
let l = match pcm_ring_consumer.pop() {
|
||||
Some(v) => v,
|
||||
None => break,
|
||||
};
|
||||
let r = pcm_ring_consumer.pop().unwrap_or(0.0);
|
||||
let mut frame = [l, r];
|
||||
crate::voice_render::limit_peak_inplace(&mut frame, 0.99);
|
||||
let (l_lim, r_lim) = (frame[0], frame[1]);
|
||||
let base = written_frames * out_channels;
|
||||
if out_channels == 1 {
|
||||
let mono = (l_lim + r_lim) * 0.5;
|
||||
out[base] = (mono.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
} else {
|
||||
out[base] = (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;
|
||||
}
|
||||
written_frames += 1;
|
||||
}
|
||||
|
||||
if written_frames < num_frames {
|
||||
let remaining = num_frames - written_frames;
|
||||
for f in 0..remaining {
|
||||
let base = (written_frames + f) * out_channels;
|
||||
for c in 0..out_channels {
|
||||
out[base + c] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||
if muted {
|
||||
for sample in &mut out[..needed] {
|
||||
*sample = 0;
|
||||
}
|
||||
} else if gain != 1.0 {
|
||||
for sample in &mut out[..needed] {
|
||||
*sample = (((*sample as f32) * gain)
|
||||
.clamp(i16::MIN as f32, i16::MAX as f32))
|
||||
as i16;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||
}
|
||||
|
||||
// iOS path: direct fill_buffer in callback. iOS VPIO
|
||||
// requests 480-frame slices that align with tsclientlib's
|
||||
// 20 ms Opus frame, so the cadence mismatch that macOS
|
||||
// hits does not arise here. macOS has its own cfg-gated
|
||||
// producer-task path above.
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
let mut scratch_stereo: Vec<f32> = vec![0.0; IOS_RENDER_SCRATCH_FRAMES * 2];
|
||||
let handler_for_render = params.handler.clone();
|
||||
let output_gain_for_render = params.output_gain.clone();
|
||||
let output_muted_for_render = params.output_muted.clone();
|
||||
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
||||
// Level meter decimation: the render callback fires ~93
|
||||
// times/sec, but the bridge consumer reads at ~30 Hz.
|
||||
let mut render_level_decimation: u32 = 0;
|
||||
// Debug WAV render-reference capture is intentionally unavailable
|
||||
// on iOS VPIO callbacks until WavDebugRecorder supports a
|
||||
// preallocated handoff; its current push path allocates per frame.
|
||||
// Diagnostic counters sampled every 100 callbacks.
|
||||
let mut cb_count: u64 = 0;
|
||||
let mut last_num_frames: usize = 0;
|
||||
let mut num_frames_changes: u64 = 0;
|
||||
let mut callbacks_with_audio: u64 = 0;
|
||||
let mut callbacks_with_silence: u64 = 0;
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let render_callback::Args {
|
||||
data, num_frames, ..
|
||||
} = args;
|
||||
let out: &mut [i16] = data.buffer;
|
||||
let out_channels = data.channels;
|
||||
let process_frames = num_frames.min(IOS_RENDER_SCRATCH_FRAMES);
|
||||
if process_frames < num_frames {
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
audio_processing_stats_for_render.increment_callback_xrun();
|
||||
// scratch_stereo is already zeroed above.
|
||||
}
|
||||
// AudioHandler produces 48 kHz stereo f32 (= frames * 2 floats).
|
||||
let needed = process_frames * 2;
|
||||
// Zero the live slice. AudioHandler::fill_buffer is
|
||||
// additive (does NOT clear); residual values from
|
||||
// earlier callbacks (when scratch was bigger) would
|
||||
// leak through otherwise.
|
||||
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();
|
||||
// scratch_stereo is already zeroed above.
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
// Never panic on the realtime IO thread.
|
||||
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
|
||||
}
|
||||
}
|
||||
// Peak limiter — multi-client mixes can sum past 0 dBFS;
|
||||
// without this the downmix helper would hard-clip to i16::MAX.
|
||||
crate::voice_render::limit_peak_inplace(&mut scratch_stereo[..needed], 0.99);
|
||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||
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,
|
||||
);
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
// Never panic on the realtime IO thread.
|
||||
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Track audio-vs-silence for the diagnostic.
|
||||
if mix_stats.peak_i16 > 0 {
|
||||
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
||||
&scratch_stereo[..needed],
|
||||
out,
|
||||
gain,
|
||||
muted,
|
||||
);
|
||||
if mix_stats.clipped_samples > 0 {
|
||||
audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples);
|
||||
}
|
||||
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;
|
||||
while idx + 1 < needed {
|
||||
let mono = (scratch_stereo[idx] + scratch_stereo[idx + 1]) * 0.5;
|
||||
render_ref_accum[render_ref_len] = mono;
|
||||
render_ref_len += 1;
|
||||
idx += 2;
|
||||
if render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
rec.push_render_reference(&render_ref_accum);
|
||||
render_ref_len = 0;
|
||||
}
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
render_recorder_active = false;
|
||||
}
|
||||
} else {
|
||||
render_recorder_active = false;
|
||||
}
|
||||
|
||||
// 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
|
||||
// Track audio-vs-silence for the diagnostic.
|
||||
if mix_stats.peak_i16 > 0 {
|
||||
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
||||
} else {
|
||||
audio_processing_stats_for_render.increment_output_underrun();
|
||||
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
||||
}
|
||||
|
||||
// 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}")))?;
|
||||
|
||||
// Finalise the unit — allocates internal buffers per the
|
||||
// stream formats we set above. After initialize() most
|
||||
@@ -1162,11 +994,7 @@ impl IosVoiceUnit {
|
||||
),
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
unit: Some(unit),
|
||||
#[cfg(target_os = "macos")]
|
||||
producer_shutdown,
|
||||
})
|
||||
Ok(Self { unit: Some(unit) })
|
||||
}
|
||||
|
||||
/// Restart the audio unit after route change handling.
|
||||
@@ -1210,11 +1038,6 @@ impl IosVoiceUnit {
|
||||
|
||||
impl Drop for IosVoiceUnit {
|
||||
fn drop(&mut self) {
|
||||
// Signal the macOS producer task to exit on its next tick
|
||||
// (up to 20 ms) so it releases its handler / ring clones.
|
||||
#[cfg(target_os = "macos")]
|
||||
self.producer_shutdown.store(true, Ordering::Relaxed);
|
||||
|
||||
// Stop the audio unit so the render callback no longer
|
||||
// fires. The coreaudio-rs wrapper's own Drop calls
|
||||
// AudioComponentInstanceDispose afterwards.
|
||||
|
||||
@@ -28,17 +28,9 @@
|
||||
|
||||
#![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_attr(not(target_os = "android"), allow(dead_code))]
|
||||
#[cfg(target_os = "android")]
|
||||
mod audio_event_queue;
|
||||
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;
|
||||
mod engine;
|
||||
pub mod frame;
|
||||
@@ -49,11 +41,6 @@ pub mod processor;
|
||||
pub mod ptt;
|
||||
pub mod ptt_backends;
|
||||
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 transmit_mode;
|
||||
pub mod transmit_selector;
|
||||
|
||||
@@ -67,7 +67,7 @@ pub type BackendEventTx = mpsc::UnboundedSender<BackendEvent>;
|
||||
pub type AudioSessionId = i32;
|
||||
|
||||
/// Engine-owned state shared with mobile voice audio callbacks.
|
||||
#[cfg_attr(target_os = "ios", derive(Clone))]
|
||||
#[cfg_attr(not(target_os = "android"), derive(Clone))]
|
||||
pub(crate) struct VoiceAudioParams {
|
||||
/// Opus-encoded voice packets sent on this channel toward the
|
||||
/// protocol layer.
|
||||
@@ -85,10 +85,7 @@ pub(crate) struct VoiceAudioParams {
|
||||
#[cfg(target_os = "android")]
|
||||
pub event_producer: crate::audio_event_queue::AudioEventProducer,
|
||||
/// AudioHandler that inbound decode+mix feeds into; the output
|
||||
/// callback pulls mixed stereo f32 from it. iOS, macOS, and desktop
|
||||
/// share this `Arc<Mutex<...>>` shape; the realtime callback uses
|
||||
/// `try_lock` so it never blocks on the tokio decode task (see
|
||||
/// `ios_voice_unit.rs` render callback).
|
||||
/// callback pulls mixed stereo f32 from it.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
|
||||
|
||||
@@ -3,18 +3,15 @@ use audiopus::{
|
||||
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
||||
SampleRate as OpusSampleRate,
|
||||
};
|
||||
use crossbeam::queue::ArrayQueue;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
||||
|
||||
use crate::AudioError;
|
||||
|
||||
pub(crate) const MAX_OPUS_FRAME: usize = 1275;
|
||||
const VOICE_FRAME_QUEUE_CAPACITY: usize = 64;
|
||||
|
||||
const VOIP_BITRATE_BPS: i32 = 32_000;
|
||||
const VOIP_COMPLEXITY: u8 = 10;
|
||||
@@ -57,129 +54,10 @@ 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.
|
||||
pub(crate) fn send_voip_frame<F, G>(
|
||||
voice_out_tx: &EncodedVoiceFrameSender,
|
||||
voice_out_tx: &mpsc::Sender<OutPacket>,
|
||||
frames_sent: &AtomicU32,
|
||||
opus_out: &[u8],
|
||||
len: usize,
|
||||
on_full: F,
|
||||
@@ -188,77 +66,16 @@ pub(crate) fn send_voip_frame<F, G>(
|
||||
F: FnOnce(),
|
||||
G: FnOnce(),
|
||||
{
|
||||
let Some(frame) = EncodedVoiceFrame::try_from_opus(opus_out, len) else {
|
||||
on_full();
|
||||
return;
|
||||
};
|
||||
match voice_out_tx.push(frame) {
|
||||
Ok(()) => {}
|
||||
Err(EncodedVoiceFrameSendError::Full) => on_full(),
|
||||
Err(EncodedVoiceFrameSendError::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"))
|
||||
);
|
||||
let packet = OutAudio::new(&AudioData::C2S {
|
||||
id: 0,
|
||||
codec: CodecType::OpusVoice,
|
||||
data: &opus_out[..len],
|
||||
});
|
||||
match voice_out_tx.try_send(packet) {
|
||||
Ok(()) => {
|
||||
frames_sent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => on_full(),
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => on_closed(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
use core::fmt;
|
||||
|
||||
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
|
||||
use thiserror::Error;
|
||||
|
||||
mod focused;
|
||||
|
||||
@@ -109,51 +108,35 @@ impl fmt::Display for PttInputClass {
|
||||
}
|
||||
|
||||
/// Errors raised by a desktop PTT backend.
|
||||
#[derive(Debug, Error)]
|
||||
#[derive(Debug)]
|
||||
pub enum PttBackendError {
|
||||
/// The OS rejected the backend initialisation (e.g. Raw Input
|
||||
/// registration failed, event tap creation failed).
|
||||
#[error("init failed: {0}")]
|
||||
Init(String),
|
||||
/// The user-granted permission required for global capture is
|
||||
/// not granted (typically macOS Input Monitoring / Accessibility).
|
||||
#[error("permission denied")]
|
||||
PermissionDenied,
|
||||
/// The display server or compositor does not expose the
|
||||
/// expected interface (typically a non-tested Linux compositor).
|
||||
#[error("unsupported environment")]
|
||||
UnsupportedEnvironment,
|
||||
/// Caller submitted a binding whose `platform_key` cannot be
|
||||
/// parsed in the active OS.
|
||||
#[error("invalid binding: {0}")]
|
||||
InvalidBinding(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ptt_backend_error_display_strings_stay_stable() {
|
||||
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 fmt::Display for PttBackendError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Init(s) => write!(f, "init failed: {s}"),
|
||||
Self::PermissionDenied => f.write_str("permission denied"),
|
||||
Self::UnsupportedEnvironment => f.write_str("unsupported environment"),
|
||||
Self::InvalidBinding(s) => write!(f, "invalid binding: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PttBackendError {}
|
||||
|
||||
/// Cross-platform desktop PTT backend (SDD-081).
|
||||
///
|
||||
/// All implementations call exactly the audio transmit gate's
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
use crate::frame::{f32_to_i16, i16_to_f32};
|
||||
use crate::{AudioError, VadBackend};
|
||||
use crate::AudioError;
|
||||
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
@@ -108,36 +108,6 @@ pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16
|
||||
detector.process_10ms(&frame)
|
||||
}
|
||||
|
||||
/// Callback-side policy for optional model-backed VAD workers.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) enum VadWorkerPolicy {
|
||||
/// Keep using the already-available model worker.
|
||||
UseWorker,
|
||||
/// No worker may be constructed on the callback thread; use WebRTC fallback.
|
||||
UseFallback,
|
||||
/// This backend does not need a model worker.
|
||||
NotModelBacked,
|
||||
}
|
||||
|
||||
/// Decide whether a realtime callback may use a model-backed VAD worker.
|
||||
///
|
||||
/// Model/worker construction is intentionally absent from this policy: if a
|
||||
/// worker is not already present, callbacks must stay nonblocking and fall back.
|
||||
pub(crate) fn callback_vad_worker_policy(
|
||||
voice_activity_mode: bool,
|
||||
backend: VadBackend,
|
||||
worker_available: bool,
|
||||
) -> VadWorkerPolicy {
|
||||
if !voice_activity_mode || backend != VadBackend::SileroOnnx {
|
||||
return VadWorkerPolicy::NotModelBacked;
|
||||
}
|
||||
if worker_available {
|
||||
VadWorkerPolicy::UseWorker
|
||||
} else {
|
||||
VadWorkerPolicy::UseFallback
|
||||
}
|
||||
}
|
||||
|
||||
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
|
||||
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
@@ -282,32 +252,4 @@ mod tests {
|
||||
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_policy_uses_existing_model_worker_only() {
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(true, VadBackend::SileroOnnx, true),
|
||||
VadWorkerPolicy::UseWorker
|
||||
);
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(true, VadBackend::SileroOnnx, false),
|
||||
VadWorkerPolicy::UseFallback
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_policy_keeps_disabled_and_webrtc_paths_worker_free() {
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(false, VadBackend::SileroOnnx, false),
|
||||
VadWorkerPolicy::NotModelBacked
|
||||
);
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(true, VadBackend::Disabled, false),
|
||||
VadWorkerPolicy::NotModelBacked
|
||||
);
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(true, VadBackend::WebrtcVad, false),
|
||||
VadWorkerPolicy::NotModelBacked
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/// Diagnostics returned by render downmix helpers.
|
||||
#[cfg(any(target_os = "ios", test))]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) struct RenderDownmixStats {
|
||||
/// Peak absolute sample magnitude after i16 conversion.
|
||||
@@ -8,45 +7,37 @@ pub(crate) struct RenderDownmixStats {
|
||||
pub clipped_samples: u64,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", test))]
|
||||
pub(crate) fn downmix_stereo_f32_to_interleaved_i16(
|
||||
/// 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.
|
||||
pub(crate) fn downmix_stereo_f32_to_mono_i16(
|
||||
stereo: &[f32],
|
||||
out: &mut [i16],
|
||||
out_channels: usize,
|
||||
gain: f32,
|
||||
muted: bool,
|
||||
) -> RenderDownmixStats {
|
||||
if out_channels == 0 {
|
||||
out.fill(0);
|
||||
return RenderDownmixStats::default();
|
||||
}
|
||||
|
||||
if muted {
|
||||
out.fill(0);
|
||||
return RenderDownmixStats::default();
|
||||
}
|
||||
|
||||
let available_frames = stereo.len() / 2;
|
||||
let requested_frames = out.len() / out_channels;
|
||||
if available_frames < requested_frames {
|
||||
if available_frames < out.len() {
|
||||
out.fill(0);
|
||||
}
|
||||
|
||||
let mut peak = 0_u16;
|
||||
let mut clipped_samples = 0_u64;
|
||||
for (dst_frame, lr) in out
|
||||
.chunks_exact_mut(out_channels)
|
||||
.zip(stereo.chunks_exact(2))
|
||||
{
|
||||
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;
|
||||
for dst in dst_frame.iter_mut() {
|
||||
*dst = sample;
|
||||
}
|
||||
*dst = sample;
|
||||
peak = peak.max(sample.unsigned_abs());
|
||||
}
|
||||
|
||||
@@ -71,29 +62,6 @@ pub(crate) fn downmix_stereo_f32_to_mono_f32(stereo: &[f32], out: &mut [f32]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// In-place per-frame peak limiter. Scales the entire buffer so the
|
||||
/// absolute peak equals `threshold`; returns the applied gain (1.0 =
|
||||
/// no reduction). Used on the render path between `AudioHandler` and
|
||||
/// the i16 downmix to prevent hard clipping when a multi-client mix
|
||||
/// exceeds 0 dBFS. Per-frame scaling is sub-millisecond at 48 kHz, so
|
||||
/// the pumping risk is negligible for speech; a look-ahead design was
|
||||
/// rejected because it would add latency on top of the existing
|
||||
/// jitter buffer.
|
||||
pub(crate) fn limit_peak_inplace(samples: &mut [f32], threshold: f32) -> f32 {
|
||||
if threshold <= 0.0 || !threshold.is_finite() {
|
||||
return 1.0;
|
||||
}
|
||||
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
|
||||
if peak <= threshold {
|
||||
return 1.0;
|
||||
}
|
||||
let gain = threshold / peak;
|
||||
for s in samples.iter_mut() {
|
||||
*s *= gain;
|
||||
}
|
||||
gain
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -103,7 +71,7 @@ mod tests {
|
||||
let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0];
|
||||
let mut out = [0_i16; 3];
|
||||
|
||||
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 2.0, false);
|
||||
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 2.0, false);
|
||||
|
||||
assert_eq!(out[0], i16::MAX);
|
||||
assert_eq!(out[1], 0);
|
||||
@@ -117,35 +85,12 @@ mod tests {
|
||||
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
|
||||
let mut out = [123_i16; 2];
|
||||
|
||||
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 1.0, true);
|
||||
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 1.0, true);
|
||||
|
||||
assert_eq!(out, [0, 0]);
|
||||
assert_eq!(stats, RenderDownmixStats::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downmix_interleaved_i16_copies_mono_to_each_channel() {
|
||||
let stereo = [1.0_f32, -1.0, 0.25, 0.25];
|
||||
let mut out = [0_i16; 4];
|
||||
|
||||
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 2, 1.0, false);
|
||||
|
||||
assert_eq!(out, [0, 0, 8191, 8191]);
|
||||
assert_eq!(stats.peak_i16, 8191);
|
||||
assert_eq!(stats.clipped_samples, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downmix_interleaved_i16_mutes_all_channels() {
|
||||
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
|
||||
let mut out = [123_i16; 6];
|
||||
|
||||
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 3, 1.0, true);
|
||||
|
||||
assert_eq!(out, [0, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(stats, RenderDownmixStats::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downmix_f32_fills_missing_tail_with_silence() {
|
||||
let stereo = [1.0_f32, -1.0];
|
||||
@@ -155,45 +100,4 @@ mod tests {
|
||||
|
||||
assert_eq!(out, [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limit_peak_is_noop_below_threshold() {
|
||||
let mut samples = [0.1_f32, -0.2, 0.3, -0.4];
|
||||
let gain = limit_peak_inplace(&mut samples, 0.95);
|
||||
assert_eq!(gain, 1.0);
|
||||
assert_eq!(samples, [0.1, -0.2, 0.3, -0.4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limit_peak_scales_above_threshold() {
|
||||
let mut samples = [0.5_f32, 1.0, 2.0, -1.5];
|
||||
let gain = limit_peak_inplace(&mut samples, 0.95);
|
||||
assert!((gain - 0.475).abs() < 1e-6, "gain = {gain}");
|
||||
assert!((samples[0] - 0.2375).abs() < 1e-6);
|
||||
assert!((samples[1] - 0.475).abs() < 1e-6);
|
||||
assert!((samples[2] - 0.95).abs() < 1e-6);
|
||||
assert!((samples[3] - (-0.7125)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limit_peak_handles_zero_and_invalid_thresholds() {
|
||||
let mut samples = [0.5_f32, 1.0];
|
||||
assert_eq!(limit_peak_inplace(&mut samples, 0.0), 1.0);
|
||||
assert_eq!(samples, [0.5, 1.0]);
|
||||
assert_eq!(limit_peak_inplace(&mut samples, -1.0), 1.0);
|
||||
assert_eq!(samples, [0.5, 1.0]);
|
||||
assert_eq!(limit_peak_inplace(&mut samples, f32::NAN), 1.0);
|
||||
assert_eq!(samples, [0.5, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limit_peak_then_downmix_produces_no_clipping() {
|
||||
// Regression: multi-client mix previously hard-clamped to i16::MAX.
|
||||
let mut scratch = [1.0_f32, 1.0, -0.5, -0.5, 0.8, 0.8];
|
||||
limit_peak_inplace(&mut scratch, 0.95);
|
||||
let mut out = [0_i16; 3];
|
||||
let stats = downmix_stereo_f32_to_interleaved_i16(&scratch, &mut out, 1, 1.0, false);
|
||||
assert_eq!(stats.clipped_samples, 0);
|
||||
assert!(stats.peak_i16 < i16::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1695,8 +1695,6 @@ pub enum BridgeEvent {
|
||||
message: String,
|
||||
/// Target scope (server/channel/private/poke).
|
||||
target: BridgeMessageTarget,
|
||||
/// Poke notification strength, present only for poke messages.
|
||||
poke_strength: Option<BridgePokeStrength>,
|
||||
},
|
||||
/// Human-readable server activity surfaced from protocol bookkeeping events.
|
||||
ServerActivity {
|
||||
@@ -1814,27 +1812,6 @@ impl From<chanora_core::MessageTarget> for BridgeMessageTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge poke notification strength.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub 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,
|
||||
}
|
||||
|
||||
impl From<chanora_core::PokeStrength> for BridgePokeStrength {
|
||||
fn from(strength: chanora_core::PokeStrength) -> Self {
|
||||
match strength {
|
||||
chanora_core::PokeStrength::Strong => Self::Strong,
|
||||
chanora_core::PokeStrength::Suppressed => Self::Suppressed,
|
||||
chanora_core::PokeStrength::SuppressedOverflow => Self::SuppressedOverflow,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge mirror of core join projection sync state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BridgeVoiceJoinSyncState {
|
||||
@@ -1981,13 +1958,11 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
|
||||
sender_name,
|
||||
message,
|
||||
target,
|
||||
poke_strength,
|
||||
} => BridgeEvent::ChatMessage {
|
||||
sender_id,
|
||||
sender_name,
|
||||
message,
|
||||
target: target.into(),
|
||||
poke_strength: poke_strength.map(Into::into),
|
||||
},
|
||||
chanora_core::SessionEvent::ServerActivity { message } => {
|
||||
BridgeEvent::ServerActivity { message }
|
||||
@@ -1997,77 +1972,27 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
|
||||
route: route.into(),
|
||||
}
|
||||
}
|
||||
chanora_core::SessionEvent::ClientMoved {
|
||||
client_id,
|
||||
new_channel_id,
|
||||
} => BridgeEvent::ClientMoved {
|
||||
client_id,
|
||||
new_channel_id,
|
||||
},
|
||||
chanora_core::SessionEvent::ClientJoined {
|
||||
client_id,
|
||||
channel_id,
|
||||
name,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} => BridgeEvent::ClientJoined {
|
||||
client_id,
|
||||
channel_id,
|
||||
name,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
},
|
||||
chanora_core::SessionEvent::ClientMoved { client_id, new_channel_id } => {
|
||||
BridgeEvent::ClientMoved { client_id, new_channel_id }
|
||||
}
|
||||
chanora_core::SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
BridgeEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
chanora_core::SessionEvent::ClientLeft { client_id, name } => {
|
||||
BridgeEvent::ClientLeft { client_id, name }
|
||||
}
|
||||
chanora_core::SessionEvent::ClientUpdated {
|
||||
client_id,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} => BridgeEvent::ClientUpdated {
|
||||
client_id,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
},
|
||||
chanora_core::SessionEvent::ChannelAdded {
|
||||
id,
|
||||
parent,
|
||||
name,
|
||||
order,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} => BridgeEvent::ChannelAdded {
|
||||
id,
|
||||
parent,
|
||||
name,
|
||||
order,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
},
|
||||
chanora_core::SessionEvent::ChannelRemoved { id } => BridgeEvent::ChannelRemoved { id },
|
||||
chanora_core::SessionEvent::ChannelUpdated {
|
||||
id,
|
||||
name,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} => BridgeEvent::ChannelUpdated {
|
||||
id,
|
||||
name,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
},
|
||||
chanora_core::SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
BridgeEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
chanora_core::SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
|
||||
BridgeEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
|
||||
}
|
||||
chanora_core::SessionEvent::ChannelRemoved { id } => {
|
||||
BridgeEvent::ChannelRemoved { id }
|
||||
}
|
||||
chanora_core::SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power } => {
|
||||
BridgeEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2292,14 +2292,11 @@ impl SseDecode for crate::api::BridgeEvent {
|
||||
let mut var_senderName = <String>::sse_decode(deserializer);
|
||||
let mut var_message = <String>::sse_decode(deserializer);
|
||||
let mut var_target = <crate::api::BridgeMessageTarget>::sse_decode(deserializer);
|
||||
let mut var_pokeStrength =
|
||||
<Option<crate::api::BridgePokeStrength>>::sse_decode(deserializer);
|
||||
return crate::api::BridgeEvent::ChatMessage {
|
||||
sender_id: var_senderId,
|
||||
sender_name: var_senderName,
|
||||
message: var_message,
|
||||
target: var_target,
|
||||
poke_strength: var_pokeStrength,
|
||||
};
|
||||
}
|
||||
11 => {
|
||||
@@ -2457,19 +2454,6 @@ impl SseDecode for crate::api::BridgeNetworkState {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgePokeStrength {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut inner = <i32>::sse_decode(deserializer);
|
||||
return match inner {
|
||||
0 => crate::api::BridgePokeStrength::Strong,
|
||||
1 => crate::api::BridgePokeStrength::Suppressed,
|
||||
2 => crate::api::BridgePokeStrength::SuppressedOverflow,
|
||||
_ => unreachable!("Invalid variant for BridgePokeStrength: {}", inner),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgePttBinding {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
@@ -2696,17 +2680,6 @@ impl SseDecode for Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for Option<crate::api::BridgePokeStrength> {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
if (<bool>::sse_decode(deserializer)) {
|
||||
return Some(<crate::api::BridgePokeStrength>::sse_decode(deserializer));
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for Option<crate::api::BridgeVoiceJoinErrorCode> {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
@@ -3326,14 +3299,12 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
|
||||
sender_name,
|
||||
message,
|
||||
target,
|
||||
poke_strength,
|
||||
} => [
|
||||
10.into_dart(),
|
||||
sender_id.into_into_dart().into_dart(),
|
||||
sender_name.into_into_dart().into_dart(),
|
||||
message.into_into_dart().into_dart(),
|
||||
target.into_into_dart().into_dart(),
|
||||
poke_strength.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart(),
|
||||
crate::api::BridgeEvent::ServerActivity { message } => {
|
||||
@@ -3513,28 +3484,6 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeNetworkState>
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgePokeStrength {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
match self {
|
||||
Self::Strong => 0.into_dart(),
|
||||
Self::Suppressed => 1.into_dart(),
|
||||
Self::SuppressedOverflow => 2.into_dart(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
|
||||
for crate::api::BridgePokeStrength
|
||||
{
|
||||
}
|
||||
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgePokeStrength>
|
||||
for crate::api::BridgePokeStrength
|
||||
{
|
||||
fn into_into_dart(self) -> crate::api::BridgePokeStrength {
|
||||
self
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgePttBinding {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
[
|
||||
@@ -4104,14 +4053,12 @@ impl SseEncode for crate::api::BridgeEvent {
|
||||
sender_name,
|
||||
message,
|
||||
target,
|
||||
poke_strength,
|
||||
} => {
|
||||
<i32>::sse_encode(10, serializer);
|
||||
<u64>::sse_encode(sender_id, serializer);
|
||||
<String>::sse_encode(sender_name, serializer);
|
||||
<String>::sse_encode(message, serializer);
|
||||
<crate::api::BridgeMessageTarget>::sse_encode(target, serializer);
|
||||
<Option<crate::api::BridgePokeStrength>>::sse_encode(poke_strength, serializer);
|
||||
}
|
||||
crate::api::BridgeEvent::ServerActivity { message } => {
|
||||
<i32>::sse_encode(11, serializer);
|
||||
@@ -4267,23 +4214,6 @@ impl SseEncode for crate::api::BridgeNetworkState {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgePokeStrength {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<i32>::sse_encode(
|
||||
match self {
|
||||
crate::api::BridgePokeStrength::Strong => 0,
|
||||
crate::api::BridgePokeStrength::Suppressed => 1,
|
||||
crate::api::BridgePokeStrength::SuppressedOverflow => 2,
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
},
|
||||
serializer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgePttBinding {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
@@ -4499,16 +4429,6 @@ impl SseEncode for Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for Option<crate::api::BridgePokeStrength> {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<bool>::sse_encode(self.is_some(), serializer);
|
||||
if let Some(value) = self {
|
||||
<crate::api::BridgePokeStrength>::sse_encode(value, serializer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for Option<crate::api::BridgeVoiceJoinErrorCode> {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use thiserror::Error;
|
||||
@@ -712,7 +712,7 @@ impl DiagnosticExport {
|
||||
/// diagnostic export and state-sync replay verification.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProtocolEventRecorder {
|
||||
events: VecDeque<String>,
|
||||
events: Vec<String>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
@@ -720,20 +720,17 @@ impl ProtocolEventRecorder {
|
||||
/// Create a recorder with the given ring-buffer capacity.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
events: VecDeque::with_capacity(capacity),
|
||||
events: Vec::with_capacity(capacity),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, ts: &str, kind: &str, detail: &str) {
|
||||
if self.capacity == 0 {
|
||||
return;
|
||||
}
|
||||
let s = format!("[{ts}] {kind}: {detail}");
|
||||
if self.events.len() >= self.capacity {
|
||||
self.events.pop_front();
|
||||
self.events.remove(0);
|
||||
}
|
||||
self.events.push_back(s);
|
||||
self.events.push(s);
|
||||
}
|
||||
|
||||
/// Record a successful connection.
|
||||
@@ -780,12 +777,12 @@ impl ProtocolEventRecorder {
|
||||
|
||||
/// Drain all recorded events and reset the buffer.
|
||||
pub fn drain(&mut self) -> Vec<String> {
|
||||
self.events.drain(..).collect()
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
/// Snapshot all recorded events without clearing the buffer.
|
||||
pub fn snapshot(&self) -> Vec<String> {
|
||||
self.events.iter().cloned().collect()
|
||||
self.events.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1106,13 +1103,4 @@ mod tests {
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(drained, first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_event_zero_capacity_drops_events() {
|
||||
let mut recorder = ProtocolEventRecorder::new(0);
|
||||
recorder.record_connected("Server");
|
||||
|
||||
assert!(recorder.snapshot().is_empty());
|
||||
assert!(recorder.drain().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,15 +41,11 @@ use crate::dto::{
|
||||
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
||||
ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||
};
|
||||
use crate::poke_limiter::PokeLimiter;
|
||||
use crate::ProtocolError;
|
||||
|
||||
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
|
||||
const INBOUND_VOICE_SEND_TIMEOUT: Duration = Duration::from_millis(40);
|
||||
const PROFILE_REFRESH_RESULT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const OUTBOUND_VOICE_PACKETS_PER_TICK: usize = 8;
|
||||
const DISCONNECT_REPLY_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const DISCONNECT_EVENT_DRAIN_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
type PendingMoves = HashMap<
|
||||
MessageHandle,
|
||||
@@ -88,30 +84,6 @@ async fn send_with_timeout<T: Send>(
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_voice_packets_for_tick<T, E>(
|
||||
voice_out_rx: &mut mpsc::Receiver<T>,
|
||||
max_packets: usize,
|
||||
mut send: impl FnMut(T) -> Result<(), E>,
|
||||
) -> usize {
|
||||
let mut drained = 0;
|
||||
for _ in 0..max_packets {
|
||||
let packet = match voice_out_rx.try_recv() {
|
||||
Ok(packet) => packet,
|
||||
Err(_) => break,
|
||||
};
|
||||
let _ = send(packet);
|
||||
drained += 1;
|
||||
}
|
||||
drained
|
||||
}
|
||||
|
||||
async fn bounded_drain_stream<S>(stream: S, timeout_duration: Duration)
|
||||
where
|
||||
S: futures::Stream,
|
||||
{
|
||||
let _ = tokio::time::timeout(timeout_duration, stream.for_each(|_| future::ready(()))).await;
|
||||
}
|
||||
|
||||
/// Pick the TeamSpeak `client_version`/platform/signature triple
|
||||
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
|
||||
/// `tsproto-types` at vendor-time) that best matches the *runtime*
|
||||
@@ -369,21 +341,8 @@ impl ProtocolClient {
|
||||
/// Disconnect cleanly. Blocks until the task exits.
|
||||
pub async fn disconnect(self) {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let request_path = async {
|
||||
if self.tx.send(Request::Disconnect(tx)).await.is_ok() {
|
||||
let _ = rx.await;
|
||||
}
|
||||
};
|
||||
|
||||
if tokio::time::timeout(DISCONNECT_REPLY_TIMEOUT, request_path)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
target: "chanora_protocol",
|
||||
timeout_ms = DISCONNECT_REPLY_TIMEOUT.as_millis() as u64,
|
||||
"disconnect request did not complete before timeout"
|
||||
);
|
||||
if self.tx.send(Request::Disconnect(tx)).await.is_ok() {
|
||||
let _ = rx.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,18 +673,15 @@ async fn connection_task(
|
||||
// reply channel — at most 3 s of pending state per move.
|
||||
let mut pending_moves: PendingMoves = HashMap::new();
|
||||
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
|
||||
let mut poke_limiter = PokeLimiter::new();
|
||||
|
||||
// Main loop: pump events, service requests, forward voice.
|
||||
loop {
|
||||
// 1. Send a bounded batch of outbound voice packets first — they're
|
||||
// time-sensitive, but control requests must still make progress.
|
||||
drain_voice_packets_for_tick(&mut voice_out_rx, OUTBOUND_VOICE_PACKETS_PER_TICK, |pkt| {
|
||||
// 1. Drain any outbound voice packets first — they're time-sensitive.
|
||||
while let Ok(pkt) = voice_out_rx.try_recv() {
|
||||
if let Err(e) = con.send_audio(pkt) {
|
||||
warn!(target: "chanora_protocol", error = %e, "send_audio failed");
|
||||
}
|
||||
Ok::<(), ()>(())
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Advance event stream by at most one event with a small timeout.
|
||||
let pump = async {
|
||||
@@ -733,20 +689,21 @@ async fn connection_task(
|
||||
tokio::time::timeout(Duration::from_millis(20), ev_stream.next()).await
|
||||
};
|
||||
match pump.await {
|
||||
Ok(Some(Ok(item))) => match item {
|
||||
StreamItem::Audio(buf) => {
|
||||
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
||||
Ok(Some(Ok(item))) => {
|
||||
match item {
|
||||
StreamItem::Audio(buf) => {
|
||||
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
||||
}
|
||||
other => handle_non_audio_stream_item(
|
||||
&con,
|
||||
other,
|
||||
&channels.chat,
|
||||
&channels.activity,
|
||||
&channels.delta,
|
||||
&mut pending_moves,
|
||||
),
|
||||
}
|
||||
other => handle_non_audio_stream_item(
|
||||
&con,
|
||||
other,
|
||||
&channels.chat,
|
||||
&channels.activity,
|
||||
&channels.delta,
|
||||
&mut pending_moves,
|
||||
&mut poke_limiter,
|
||||
),
|
||||
},
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
warn!(target: "chanora_protocol", error = %e, "event error");
|
||||
// Some errors are transient; treat persistent ones
|
||||
@@ -844,14 +801,13 @@ async fn connection_task(
|
||||
&channels,
|
||||
&mut pending_moves,
|
||||
&mut voice_activity,
|
||||
&mut poke_limiter,
|
||||
)
|
||||
.await;
|
||||
let _ = reply.send(r);
|
||||
}
|
||||
Ok(Request::Disconnect(reply)) => {
|
||||
let _ = con.disconnect(DisconnectOptions::new());
|
||||
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
let _ = reply.send(());
|
||||
info!(target: "chanora_protocol", "clean disconnect");
|
||||
exit!(DisconnectReason::UserRequested);
|
||||
@@ -859,7 +815,7 @@ async fn connection_task(
|
||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
let _ = con.disconnect(DisconnectOptions::new());
|
||||
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
info!(target: "chanora_protocol", "handle dropped; implicit disconnect");
|
||||
exit!(DisconnectReason::UserRequested);
|
||||
}
|
||||
@@ -907,7 +863,6 @@ fn handle_non_audio_stream_item(
|
||||
activity_tx: &mpsc::Sender<ServerActivity>,
|
||||
delta_tx: &mpsc::Sender<ProtocolDelta>,
|
||||
pending_moves: &mut PendingMoves,
|
||||
poke_limiter: &mut PokeLimiter,
|
||||
) {
|
||||
match item {
|
||||
StreamItem::BookEvents(events) => {
|
||||
@@ -969,25 +924,17 @@ fn handle_non_audio_stream_item(
|
||||
message,
|
||||
} = ev
|
||||
{
|
||||
let (mapped, poke_strength) = match target {
|
||||
tsclientlib::MessageTarget::Server => (MessageTarget::Server, None),
|
||||
tsclientlib::MessageTarget::Channel => (MessageTarget::Channel, None),
|
||||
tsclientlib::MessageTarget::Client(id) => {
|
||||
(MessageTarget::Client(id.0 as u64), None)
|
||||
}
|
||||
tsclientlib::MessageTarget::Poke(id) => {
|
||||
let own_client_id =
|
||||
con.get_state().ok().map(|state| state.own_client.0 as u64);
|
||||
let strength = poke_limiter.record(invoker.id.0 as u64, own_client_id);
|
||||
(MessageTarget::Poke(id.0 as u64), Some(strength))
|
||||
}
|
||||
let mapped = match target {
|
||||
tsclientlib::MessageTarget::Server => MessageTarget::Server,
|
||||
tsclientlib::MessageTarget::Channel => MessageTarget::Channel,
|
||||
tsclientlib::MessageTarget::Client(id) => MessageTarget::Client(id.0 as u64),
|
||||
tsclientlib::MessageTarget::Poke(id) => MessageTarget::Poke(id.0 as u64),
|
||||
};
|
||||
let _ = chat_tx.try_send(ChatMessage {
|
||||
sender_id: ClientId(invoker.id.0 as u64),
|
||||
sender_name: sanitize(&invoker.name),
|
||||
message: sanitize(&message),
|
||||
target: mapped,
|
||||
poke_strength,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1175,19 +1122,10 @@ async fn fetch_client_profile(
|
||||
channels: &EventChannels,
|
||||
pending_moves: &mut PendingMoves,
|
||||
voice_activity: &mut HashMap<u64, Instant>,
|
||||
poke_limiter: &mut PokeLimiter,
|
||||
) -> Result<ClientProfile, ProtocolError> {
|
||||
let target_id = TsClientId(client_id as u16);
|
||||
|
||||
let (
|
||||
database_id,
|
||||
uid_b64,
|
||||
has_optional,
|
||||
has_connection,
|
||||
is_own,
|
||||
needs_server_groups,
|
||||
needs_channel_groups,
|
||||
) = {
|
||||
let (database_id, uid_b64, has_optional, has_connection, is_own, needs_server_groups, needs_channel_groups) = {
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
@@ -1221,7 +1159,6 @@ async fn fetch_client_profile(
|
||||
channels,
|
||||
pending_moves,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1232,7 +1169,6 @@ async fn fetch_client_profile(
|
||||
channels,
|
||||
pending_moves,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1247,7 +1183,6 @@ async fn fetch_client_profile(
|
||||
channels,
|
||||
pending_moves,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1266,7 +1201,6 @@ async fn fetch_client_profile(
|
||||
channels,
|
||||
pending_moves,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1286,7 +1220,6 @@ async fn fetch_client_profile(
|
||||
channels,
|
||||
pending_moves,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
@@ -1356,18 +1289,10 @@ async fn fetch_client_profile(
|
||||
.or_else(|| db_info.as_ref().map(|info| info.created.unix_timestamp())),
|
||||
last_connected_unix_seconds: optional
|
||||
.map(|info| info.last_connected.unix_timestamp())
|
||||
.or_else(|| {
|
||||
db_info
|
||||
.as_ref()
|
||||
.map(|info| info.last_connected.unix_timestamp())
|
||||
}),
|
||||
.or_else(|| db_info.as_ref().map(|info| info.last_connected.unix_timestamp())),
|
||||
connections_total: optional
|
||||
.map(|info| u64::from(info.connections_total))
|
||||
.or_else(|| {
|
||||
db_info
|
||||
.as_ref()
|
||||
.map(|info| u64::from(info.connections_total))
|
||||
}),
|
||||
.or_else(|| db_info.as_ref().map(|info| u64::from(info.connections_total))),
|
||||
online_seconds: connection
|
||||
.and_then(|info| info.connected_time.map(|duration| duration.whole_seconds())),
|
||||
idle_milliseconds: connection.map(|info| duration_millis(info.idle_time)),
|
||||
@@ -1400,10 +1325,14 @@ async fn fetch_client_profile(
|
||||
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_total)),
|
||||
packet_loss_client_to_server_total: net_stats
|
||||
.map(|s| s.get_packetloss())
|
||||
.or_else(|| connection.map(|info| info.client_to_server_packetloss_total)),
|
||||
.or_else(|| {
|
||||
connection.map(|info| info.client_to_server_packetloss_total)
|
||||
}),
|
||||
packet_loss_server_to_client_total: net_stats
|
||||
.map(|s| s.get_packetloss_s2c_total())
|
||||
.or_else(|| connection.and_then(|info| info.server_to_client_packetloss_total)),
|
||||
.or_else(|| {
|
||||
connection.and_then(|info| info.server_to_client_packetloss_total)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1449,7 +1378,6 @@ async fn request_messages(
|
||||
channels: &EventChannels,
|
||||
pending_moves: &mut PendingMoves,
|
||||
voice_activity: &mut HashMap<u64, Instant>,
|
||||
poke_limiter: &mut PokeLimiter,
|
||||
) -> Result<Vec<InMessage>, ProtocolError> {
|
||||
let handle = command
|
||||
.send_with_result(con)
|
||||
@@ -1492,7 +1420,6 @@ async fn request_messages(
|
||||
&channels.activity,
|
||||
&channels.delta,
|
||||
pending_moves,
|
||||
poke_limiter,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1504,7 +1431,6 @@ async fn request_client_db_info(
|
||||
channels: &EventChannels,
|
||||
pending_moves: &mut PendingMoves,
|
||||
voice_activity: &mut HashMap<u64, Instant>,
|
||||
poke_limiter: &mut PokeLimiter,
|
||||
) -> Result<InClientDbInfoPart, ProtocolError> {
|
||||
let messages = request_messages(
|
||||
con,
|
||||
@@ -1512,7 +1438,6 @@ async fn request_client_db_info(
|
||||
channels,
|
||||
pending_moves,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
.await?;
|
||||
for message in messages {
|
||||
@@ -1951,12 +1876,10 @@ const _: () = {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
bounded_drain_stream, client_profile_refresh_plan, drain_voice_packets_for_tick,
|
||||
is_server_query_client_type, send_with_timeout, server_socket_from_config,
|
||||
sort_channels_tree_by, std_duration_millis, ConnectConfig, ProtocolClient, Request,
|
||||
SendTimeoutError, DISCONNECT_REPLY_TIMEOUT,
|
||||
client_profile_refresh_plan, is_server_query_client_type, send_with_timeout,
|
||||
server_socket_from_config, sort_channels_tree_by, std_duration_millis,
|
||||
ConnectConfig, SendTimeoutError,
|
||||
};
|
||||
use futures::stream;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tsproto_types::ClientType;
|
||||
@@ -2199,83 +2122,6 @@ mod tests {
|
||||
|
||||
assert_eq!(result, Err(SendTimeoutError::Timeout(2)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_request_send_is_bounded_when_request_channel_is_full() {
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
let (reply_tx, _reply_rx) = tokio::sync::oneshot::channel();
|
||||
tx.send(Request::Snapshot(reply_tx))
|
||||
.await
|
||||
.expect("seed first request");
|
||||
|
||||
let (disconnect_tx, _disconnect_rx) = tokio::sync::oneshot::channel();
|
||||
let result = send_with_timeout(
|
||||
&tx,
|
||||
Request::Disconnect(disconnect_tx),
|
||||
Duration::from_millis(10),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(SendTimeoutError::Timeout(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protocol_client_disconnect_returns_when_request_channel_is_full() {
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
let (snapshot_tx, _snapshot_rx) = tokio::sync::oneshot::channel();
|
||||
tx.send(Request::Snapshot(snapshot_tx))
|
||||
.await
|
||||
.expect("seed first request");
|
||||
let (voice_out_tx, _voice_out_rx) = mpsc::channel(1);
|
||||
let (_voice_in_tx, voice_in_rx) = mpsc::channel(1);
|
||||
let (_lost_tx, lost_rx) = tokio::sync::oneshot::channel();
|
||||
let (_chat_tx, chat_rx) = mpsc::channel(1);
|
||||
let (_activity_tx, activity_rx) = mpsc::channel(1);
|
||||
let (_delta_tx, delta_rx) = mpsc::channel(1);
|
||||
let client = ProtocolClient {
|
||||
tx,
|
||||
voice_out_tx,
|
||||
voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)),
|
||||
lost_rx: std::sync::Mutex::new(Some(lost_rx)),
|
||||
chat_rx: std::sync::Mutex::new(Some(chat_rx)),
|
||||
activity_rx: std::sync::Mutex::new(Some(activity_rx)),
|
||||
delta_rx: std::sync::Mutex::new(Some(delta_rx)),
|
||||
};
|
||||
|
||||
tokio::time::timeout(
|
||||
DISCONNECT_REPLY_TIMEOUT + Duration::from_millis(100),
|
||||
client.disconnect(),
|
||||
)
|
||||
.await
|
||||
.expect("disconnect should not wait indefinitely for request channel capacity");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn voice_drain_stops_at_per_tick_budget() {
|
||||
let (tx, mut rx) = mpsc::channel(8);
|
||||
for value in 0_u8..5 {
|
||||
tx.send(value).await.expect("seed voice packet");
|
||||
}
|
||||
|
||||
let mut sent = Vec::new();
|
||||
let drained = drain_voice_packets_for_tick(&mut rx, 2, |value| {
|
||||
sent.push(value);
|
||||
Ok::<(), ()>(())
|
||||
});
|
||||
|
||||
assert_eq!(drained, 2);
|
||||
assert_eq!(sent, vec![0, 1]);
|
||||
assert_eq!(rx.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_stream_drain_returns_after_timeout() {
|
||||
let start = tokio::time::Instant::now();
|
||||
|
||||
bounded_drain_stream(stream::pending::<()>(), Duration::from_millis(10)).await;
|
||||
|
||||
assert!(start.elapsed() < Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn forward_delta(
|
||||
@@ -2358,7 +2204,9 @@ fn forward_delta(
|
||||
old: PropertyValue::Channel(channel),
|
||||
..
|
||||
} => {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved { id: channel.id.0 });
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved {
|
||||
id: channel.id.0,
|
||||
});
|
||||
}
|
||||
Event::PropertyChanged {
|
||||
id: PropertyId::Channel(channel_id),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user