Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc52092654 | ||
|
|
a644770488 | ||
|
|
5a1d902795 | ||
|
|
901369b072 | ||
|
|
57a4d9767b | ||
|
|
8c4f85ee70 | ||
|
|
7c341d42e5 | ||
|
|
8487acf167 | ||
|
|
8606eb48c8 | ||
|
|
d83539436e | ||
|
|
e4fdf8414a | ||
|
|
0f41993ed0 | ||
|
|
a0ff17b935 | ||
|
|
68a7892e19 | ||
|
|
e048b6b6bd | ||
|
|
dad633e381 | ||
|
|
e9cd832828 | ||
|
|
c40705790a | ||
|
|
76fe8faa94 | ||
|
|
5257b1e3ac | ||
|
|
0d37550d43 | ||
|
|
fe5dc1cda4 | ||
|
|
581353b6d2 | ||
|
|
1cf1a8f5a6 | ||
|
|
d59da05f93 | ||
|
|
eb10db5b59 | ||
|
|
74951dd7b4 | ||
|
|
ebae1274d6 | ||
|
|
23bfddb6b4 | ||
|
|
1bc2fccd0a | ||
|
|
80c73f34c3 | ||
|
|
9d8a1f8fd1 | ||
|
|
a589ac953f | ||
|
|
ad8b996376 | ||
|
|
ab0dc2ebc2 | ||
|
|
5e8b7915db | ||
|
|
e7f7c55b30 | ||
|
|
bb73a94e2c | ||
|
|
e3286f1197 | ||
|
|
957d68f39d | ||
|
|
8105b7af21 | ||
|
|
5f1423c349 | ||
|
|
82441f3d97 | ||
|
|
2c7b68e21e | ||
|
|
12e3a1f4ee | ||
|
|
2902a8bcd5 | ||
|
|
fb2a8e0a80 | ||
|
|
d3bb199208 |
@@ -0,0 +1,33 @@
|
||||
name: opencode
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
opencode:
|
||||
if: |
|
||||
contains(github.event.comment.body, ' /oc') ||
|
||||
startsWith(github.event.comment.body, '/oc') ||
|
||||
contains(github.event.comment.body, ' /opencode') ||
|
||||
startsWith(github.event.comment.body, '/opencode')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@latest
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
with:
|
||||
model: zhipuai-coding-plan/glm-5.1
|
||||
+17
@@ -117,6 +117,23 @@ opencode.json
|
||||
|
||||
# iOS framework build artifacts produced by chanora_bridge.podspec
|
||||
/apps/chanora_flutter/ios/Frameworks/
|
||||
|
||||
# macOS framework build artifacts produced by chanora_bridge.podspec
|
||||
# (prepare_command + script_phase rm -rf and regenerate this tree on
|
||||
# every pod install AND every Xcode build, so tracking it in git is
|
||||
# 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
|
||||
|
||||
|
||||
+16
-12
@@ -8,8 +8,10 @@ This project follows a Conventional Commits style workflow.
|
||||
|
||||
The v0.3.0 milestone transitions Chanora from an internal-beta voice
|
||||
prototype to a cross-platform baseline client with event-driven UI,
|
||||
per-user audio controls, non-self client info parity, and CI-hardened
|
||||
Android / iOS / macOS / Linux builds.
|
||||
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.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -17,10 +19,9 @@ Android / iOS / macOS / Linux builds.
|
||||
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-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.
|
||||
- **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.
|
||||
- **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
|
||||
@@ -29,9 +30,10 @@ Android / iOS / macOS / Linux builds.
|
||||
- **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** as the preferred voice activity detector
|
||||
on iOS / macOS when the private `silero-coreml` SwiftPM submodule is
|
||||
available. WebRTC VAD remains the runtime fallback.
|
||||
- **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.
|
||||
- **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
|
||||
lookups and `ts3server://` URI handling.
|
||||
- **Per-ABI Android APK splitting.** `flutter build apk
|
||||
@@ -50,8 +52,9 @@ Android / iOS / macOS / Linux builds.
|
||||
- **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
|
||||
serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
|
||||
audio startup.
|
||||
- **Linux native audio path promoted** with ONNX Runtime bundled for
|
||||
VAD. Desktop voice I/O works on PipeWire / PulseAudio.
|
||||
- **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.
|
||||
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
|
||||
startup permission flow, and system back-button integration.
|
||||
- **`SnapshotChanged` event removed.** Replaced by the typed delta
|
||||
@@ -59,7 +62,8 @@ Android / iOS / macOS / Linux builds.
|
||||
Flutter).
|
||||
- **Prefetch crate renamed** from the PoC-era name to
|
||||
`chanora_prefetch`. All docs, specs, and code updated.
|
||||
- **Build number bumped to 76.**
|
||||
- **Flutter app version/build bumped to `0.3.0+100`.** Rust workspace
|
||||
packages remain versioned separately at `0.2.0-beta.1`.
|
||||
- **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
Generated
+94
-74
@@ -70,6 +70,15 @@ 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"
|
||||
@@ -419,19 +428,21 @@ name = "chanora_audio"
|
||||
version = "0.2.0-beta.1"
|
||||
dependencies = [
|
||||
"audiopus",
|
||||
"bytemuck",
|
||||
"chanora_protocol",
|
||||
"coreaudio-rs",
|
||||
"cpal",
|
||||
"criterion",
|
||||
"crossbeam",
|
||||
"dhat",
|
||||
"dispatch2",
|
||||
"futures-util",
|
||||
"jni 0.21.1",
|
||||
"jni 0.22.4",
|
||||
"ndarray",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
"ort",
|
||||
"rand 0.8.6",
|
||||
"rand 0.10.1",
|
||||
"rustfft",
|
||||
"sdl2",
|
||||
"serde_json",
|
||||
@@ -442,7 +453,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"tsclientlib",
|
||||
"webrtc-vad",
|
||||
"windows 0.54.0",
|
||||
"windows",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
@@ -521,7 +532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chanora_resolver"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0-beta.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hickory-resolver",
|
||||
@@ -715,14 +726,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.17.3"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7"
|
||||
checksum = "d9dd2b2151ebb4d5866c804d89fe28244bfb6b74481b9b4d406e4ec4d7f88ce5"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"block2",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni 0.21.1",
|
||||
"jni 0.22.4",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
@@ -737,10 +749,9 @@ dependencies = [
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.62.2",
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -763,25 +774,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.5.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
|
||||
dependencies = [
|
||||
"alloca",
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.13.0",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
"page_size",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
@@ -789,12 +799,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.5.0"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -803,6 +813,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-queue",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
@@ -831,6 +852,15 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
@@ -1963,7 +1993,7 @@ dependencies = [
|
||||
"socket2",
|
||||
"widestring",
|
||||
"windows-registry",
|
||||
"windows-result 0.4.1",
|
||||
"windows-result",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -1976,22 +2006,11 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
@@ -2216,12 +2235,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
@@ -2518,6 +2534,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||
dependencies = [
|
||||
"bitflags 2.12.1",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -2730,6 +2747,16 @@ 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"
|
||||
@@ -3476,9 +3503,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sdl2"
|
||||
version = "0.37.0"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b498da7d14d1ad6c839729bd4ad6fc11d90a57583605f3b4df2cd709a9cd380"
|
||||
checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"lazy_static",
|
||||
@@ -3488,9 +3515,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sdl2-sys"
|
||||
version = "0.37.0"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "951deab27af08ed9c6068b7b0d05a93c91f0a8eb16b6b816a5e73452a43521d3"
|
||||
checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
@@ -4746,6 +4773,22 @@ 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"
|
||||
@@ -4756,14 +4799,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
@@ -4772,7 +4811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
@@ -4783,17 +4822,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"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",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4805,7 +4834,7 @@ dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
@@ -4815,7 +4844,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
@@ -4854,7 +4883,7 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
@@ -4865,19 +4894,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-result",
|
||||
"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,3 +82,17 @@ 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 in early planning and baseline-candidate design.
|
||||
Chanora is currently a baseline-candidate Flutter + Rust workspace. It is not production-ready and is not approved for public or store release.
|
||||
|
||||
```text
|
||||
Current documentation baseline: v0.9.2
|
||||
Current documentation baseline: v0.9.x document set
|
||||
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;
|
||||
- preparing the Flutter + Rust application structure;
|
||||
- hardening 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 24+ unless Flutter, plugin, audio, or product constraints require raising it |
|
||||
| Android runtime target | Android API 28+ per DEC-004, SysRS-288, SRS-187, and Gradle `minSdk = 28` |
|
||||
| Google Play target API | Target the Google Play-required API level on upload date |
|
||||
|
||||
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 may be added later. A likely structure is:
|
||||
Implementation source folders are present in this workspace. The current high-level structure is:
|
||||
|
||||
```text
|
||||
apps/
|
||||
@@ -248,7 +248,7 @@ crates/
|
||||
chanora_bridge/
|
||||
```
|
||||
|
||||
The exact implementation layout should be finalized when the repository scaffold is created.
|
||||
The exact implementation layout may continue to evolve as maintainability reviews split or merge Modules, but the repository scaffold exists.
|
||||
|
||||
---
|
||||
|
||||
@@ -395,9 +395,7 @@ docs/governance/git-commit-message-convention.md
|
||||
|
||||
## Development
|
||||
|
||||
Implementation commands will be added after the repository scaffold is finalized.
|
||||
|
||||
Expected future commands may include:
|
||||
Common local commands include:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
@@ -407,7 +405,7 @@ cargo clippy
|
||||
cargo fmt
|
||||
```
|
||||
|
||||
Do not treat these as authoritative until the actual Flutter/Rust workspace has been created.
|
||||
Android runtime success also requires an available Android NDK toolchain and an authorized device or emulator for build/install/smoke verification.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -63,8 +63,10 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -19,8 +19,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
id("com.android.application") version "8.13.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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,2 +1,6 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.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,2 +1,27 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.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 'Runner' do
|
||||
target 'Chanora' do
|
||||
use_frameworks!
|
||||
|
||||
# Chanora Rust bridge as a vendored framework. The podspec runs
|
||||
|
||||
@@ -1,70 +1,33 @@
|
||||
PODS:
|
||||
- audio_session (0.0.1):
|
||||
- Flutter
|
||||
- chanora_bridge (1.0.0)
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_foreground_task (0.0.1):
|
||||
- Flutter
|
||||
- haptic_kit (1.0.0):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||
- chanora_bridge (from `.`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
|
||||
- haptic_kit (from `.symlinks/plugins/haptic_kit/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_session:
|
||||
:path: ".symlinks/plugins/audio_session/ios"
|
||||
chanora_bridge:
|
||||
:path: "."
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_foreground_task:
|
||||
:path: ".symlinks/plugins/flutter_foreground_task/ios"
|
||||
haptic_kit:
|
||||
:path: ".symlinks/plugins/haptic_kit/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||
chanora_bridge: 2ed7c2ba427fab135dd9eab66c507b09cfee113a
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
chanora_bridge: 26252acdf9ca660ce9c132ad25cd5ad5af467b16
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
||||
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
|
||||
PODFILE CHECKSUM: e2123068539aeb66d53dc1612b383d13f489ede2
|
||||
PODFILE CHECKSUM: 85b93b53f958f1ff700a147e9da4374c8b1c6970
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -8,17 +8,18 @@
|
||||
|
||||
/* 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 */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -47,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>"; };
|
||||
@@ -59,19 +60,23 @@
|
||||
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>"; };
|
||||
8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SileroCoreMLBridge.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; 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 /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146EE1CF9000F007C117D /* Chanora.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Chanora.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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -79,8 +84,9 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */,
|
||||
1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */,
|
||||
C8BACE02E6EE5F840EE3F174 /* Pods_Chanora.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -106,8 +112,8 @@
|
||||
4351F25046559EFA4C03047A /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0B4754099284EEDCD859A973 /* Pods_Runner.framework */,
|
||||
63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */,
|
||||
DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -121,6 +127,9 @@
|
||||
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>";
|
||||
@@ -128,6 +137,7 @@
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
@@ -151,7 +161,7 @@
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
97C146EE1CF9000F007C117D /* Chanora.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
@@ -197,14 +207,15 @@
|
||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
97C146ED1CF9000F007C117D /* Chanora */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Chanora" */;
|
||||
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 */,
|
||||
@@ -214,12 +225,13 @@
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
name = Chanora;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
8C5000032DD0000000000001 /* SileroCoreML */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Chanora.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@@ -252,13 +264,14 @@
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
97C146ED1CF9000F007C117D /* Chanora */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
};
|
||||
@@ -331,15 +344,15 @@
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */ = {
|
||||
@@ -357,7 +370,7 @@
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
"$(DERIVED_FILE_DIR)/Pods-Chanora-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
@@ -379,6 +392,21 @@
|
||||
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 */
|
||||
@@ -406,7 +434,7 @@
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
target = 97C146ED1CF9000F007C117D /* Chanora */;
|
||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
@@ -490,18 +518,23 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 349G7M4TQQ;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 101;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
|
||||
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.chanora.chanoraFlutter;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Chanora_iOS_Ad Hoc";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Chanora_iOS_Ad Hoc";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
@@ -522,7 +555,7 @@
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -538,7 +571,7 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@@ -554,7 +587,7 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
@@ -676,18 +709,23 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 349G7M4TQQ;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 101;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
|
||||
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.chanora.chanoraFlutter;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
PROVISIONING_PROFILE_SPECIFIER = Chanora_ios_Development;
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = Chanora_ios_Development;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -702,18 +740,23 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 349G7M4TQQ;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 101;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
|
||||
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.chanora.chanoraFlutter;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Chanora_App Store";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Chanora_App Store";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
@@ -743,7 +786,7 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Chanora" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147061CF9000F007C117D /* Debug */,
|
||||
@@ -756,13 +799,21 @@
|
||||
/* 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";
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
8C5000032DD0000000000001 /* SileroCoreML */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Chanora.app"
|
||||
BlueprintName = "Chanora"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@@ -15,8 +33,8 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
BuildableName = "Chanora.app"
|
||||
BlueprintName = "Chanora"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
@@ -32,8 +50,8 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
BuildableName = "Chanora.app"
|
||||
BlueprintName = "Chanora"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
@@ -68,8 +86,8 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
BuildableName = "Chanora.app"
|
||||
BlueprintName = "Chanora"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
@@ -85,8 +103,8 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
BuildableName = "Chanora.app"
|
||||
BlueprintName = "Chanora"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
|
||||
@@ -6,95 +6,53 @@ 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 {
|
||||
// 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)")
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
ChanoraSileroSelfTest.run()
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
// 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
|
||||
// by the BridgeEvent::AudioStarted / AudioStopped lifecycle.
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||
logAudioSessionState(context: "launch-ambient")
|
||||
} catch {
|
||||
NSLog("chanora_flutter: AVAudioSession .ambient baseline failed: \(error)")
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
@@ -120,37 +78,60 @@ import AVFoundation
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
/// Activate the VoIP audio session. Called from Dart via the
|
||||
/// `chanora/ios_audio_session` channel when a voice channel join
|
||||
/// reaches the `BridgeEvent::AudioStarted` stage. 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() {
|
||||
do {
|
||||
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: ",")
|
||||
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: ",")
|
||||
NSLog(
|
||||
"chanora_flutter: AVAudioSession actual: " +
|
||||
"sampleRate=\(s.sampleRate) " +
|
||||
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
|
||||
"inputs=[\(ins)] " +
|
||||
"outputVolume=\(s.outputVolume)"
|
||||
"chanora_flutter: voice session active: " +
|
||||
"sampleRate=\(session.sampleRate) " +
|
||||
"ioBufferDuration=\(String(format: "%.4f", session.ioBufferDuration)) " +
|
||||
"inputs=[\(ins)] outputVolume=\(session.outputVolume)"
|
||||
)
|
||||
} catch {
|
||||
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
|
||||
NSLog("chanora_flutter: activateVoiceSession failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Deactivate the VoIP audio session and return to the idle
|
||||
/// .ambient baseline. Called from Dart on `BridgeEvent::AudioStopped`
|
||||
/// (intentional leave, disconnect, or connection lost).
|
||||
/// `.notifyOthersOnDeactivation` lets other audio apps know they
|
||||
/// can resume — best-effort: Apple Music / Podcasts resume
|
||||
/// reliably, Spotify is not guaranteed.
|
||||
private func deactivateVoiceSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setActive(false, options: [.notifyOthersOnDeactivation])
|
||||
} catch {
|
||||
NSLog("chanora_flutter: deactivateVoiceSession setActive(false) failed: \(error)")
|
||||
}
|
||||
do {
|
||||
try session.setCategory(.ambient, mode: .default)
|
||||
} catch {
|
||||
NSLog("chanora_flutter: deactivateVoiceSession setCategory(.ambient) failed: \(error)")
|
||||
}
|
||||
voiceSessionActive = false
|
||||
logAudioSessionState(context: "deactivateVoiceSession")
|
||||
}
|
||||
|
||||
/// Reads back the actual AVAudioSession state and logs it for
|
||||
/// SDD-098 compliance. Called after both setCategory and setActive
|
||||
/// to verify that the session accepted the requested configuration.
|
||||
@@ -215,25 +196,30 @@ import AVFoundation
|
||||
}
|
||||
|
||||
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
||||
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)")
|
||||
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)")
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
@@ -265,6 +251,26 @@ 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,9 +4,6 @@
|
||||
<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>
|
||||
@@ -27,10 +24,16 @@
|
||||
<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 TeamSpeak-compatible voice server.</string>
|
||||
<string>Chanora needs microphone access so you can talk on your voice server.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
@@ -58,8 +61,8 @@
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -77,9 +80,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import CoreML
|
||||
import Darwin
|
||||
import Foundation
|
||||
import SileroCoreML
|
||||
|
||||
@@ -96,3 +97,136 @@ 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,6 +85,13 @@ 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" \\
|
||||
@@ -95,6 +102,9 @@ 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
|
||||
@@ -129,7 +139,21 @@ PLIST
|
||||
|
||||
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
||||
"$FW/chanora_bridge"
|
||||
echo "[chanora_bridge.podspec] framework ready at $FW"
|
||||
|
||||
# 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"
|
||||
SCRIPT
|
||||
|
||||
# Pod CocoaPods picks this up; the framework gets embedded into
|
||||
@@ -185,6 +209,13 @@ 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"
|
||||
@@ -203,6 +234,9 @@ 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"
|
||||
@@ -210,15 +244,19 @@ 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).
|
||||
# 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
|
||||
if [ -f "$FW/chanora_bridge" ] && [ "$FW/chanora_bridge" -nt "$BRIDGE" ]; then
|
||||
echo "[chanora_bridge script_phase] framework already up-to-date"
|
||||
exit 0
|
||||
FW_UP_TO_DATE=1
|
||||
fi
|
||||
|
||||
mkdir -p "$FW"
|
||||
cp "$BRIDGE" "$FW/chanora_bridge"
|
||||
cat > "$FW/Info.plist" <<PLIST
|
||||
if [ "$FW_UP_TO_DATE" = 0 ]; then
|
||||
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">
|
||||
@@ -234,9 +272,28 @@ PLIST
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
||||
"$FW/chanora_bridge"
|
||||
echo "[chanora_bridge script_phase] framework refreshed"
|
||||
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
|
||||
SCRIPT
|
||||
:execution_position => :before_compile,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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,6 +46,7 @@
|
||||
"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",
|
||||
@@ -298,5 +299,6 @@
|
||||
"clientVolumeMuteAction": "Mute user",
|
||||
"clientVolumeUnmuteAction": "Unmute user",
|
||||
"clientVolumeResetAction": "Reset to default",
|
||||
"permissionDenied": "Permission Denied"
|
||||
"permissionDenied": "Permission Denied",
|
||||
"voiceTalkPowerBlocked": "Insufficient talk power to speak in this channel"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"retryAction": "重试",
|
||||
"chatAction": "聊天",
|
||||
"chatCloseAction": "关闭聊天",
|
||||
"chatPanelCollapsedHint": "点击聊天按钮以继续对话",
|
||||
"chatNewPrivateAction": "新建私聊",
|
||||
"chatSearchClientsHint": "搜索用户...",
|
||||
"chatDirectMessageAction": "私聊",
|
||||
@@ -241,5 +242,6 @@
|
||||
"clientVolumeMuteAction": "静音该用户",
|
||||
"clientVolumeUnmuteAction": "取消静音",
|
||||
"clientVolumeResetAction": "恢复默认",
|
||||
"permissionDenied": "权限被拒绝"
|
||||
"permissionDenied": "权限被拒绝",
|
||||
"voiceTalkPowerBlocked": "发言权限不足,无法在此频道发言"
|
||||
}
|
||||
|
||||
@@ -307,6 +307,12 @@ 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:
|
||||
@@ -1246,6 +1252,12 @@ abstract class AppL10n {
|
||||
/// In en, this message translates to:
|
||||
/// **'Permission Denied'**
|
||||
String get permissionDenied;
|
||||
|
||||
/// No description provided for @voiceTalkPowerBlocked.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Insufficient talk power to speak in this channel'**
|
||||
String get voiceTalkPowerBlocked;
|
||||
}
|
||||
|
||||
class _AppL10nDelegate extends LocalizationsDelegate<AppL10n> {
|
||||
|
||||
@@ -123,6 +123,10 @@ 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';
|
||||
|
||||
@@ -653,4 +657,8 @@ class AppL10nEn extends AppL10n {
|
||||
|
||||
@override
|
||||
String get permissionDenied => 'Permission Denied';
|
||||
|
||||
@override
|
||||
String get voiceTalkPowerBlocked =>
|
||||
'Insufficient talk power to speak in this channel';
|
||||
}
|
||||
|
||||
@@ -120,6 +120,9 @@ class AppL10nZh extends AppL10n {
|
||||
@override
|
||||
String get chatCloseAction => '关闭聊天';
|
||||
|
||||
@override
|
||||
String get chatPanelCollapsedHint => '点击聊天按钮以继续对话';
|
||||
|
||||
@override
|
||||
String get chatNewPrivateAction => '新建私聊';
|
||||
|
||||
@@ -641,4 +644,7 @@ class AppL10nZh extends AppL10n {
|
||||
|
||||
@override
|
||||
String get permissionDenied => '权限被拒绝';
|
||||
|
||||
@override
|
||||
String get voiceTalkPowerBlocked => '发言权限不足,无法在此频道发言';
|
||||
}
|
||||
|
||||
+647
-359
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,22 @@ 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':
|
||||
@@ -14,11 +29,15 @@ 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;
|
||||
}
|
||||
@@ -27,6 +46,7 @@ rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
|
||||
void wireAudioLifecycle() {
|
||||
wireIosAudioLifecycle();
|
||||
wireAndroidAudioLifecycle();
|
||||
wireMacosAudioLifecycle();
|
||||
}
|
||||
|
||||
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
|
||||
@@ -105,3 +125,42 @@ 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.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
class HardMuteOwners {
|
||||
const HardMuteOwners({
|
||||
this.manual = false,
|
||||
this.permission = false,
|
||||
this.talkPower = false,
|
||||
});
|
||||
|
||||
final bool manual;
|
||||
final bool permission;
|
||||
final bool talkPower;
|
||||
|
||||
bool get effective => manual || permission || talkPower;
|
||||
|
||||
HardMuteOwners withBridgeManualMute(bool muted) {
|
||||
return copyWith(
|
||||
manual: muted && (manual || !permission && !talkPower),
|
||||
);
|
||||
}
|
||||
|
||||
HardMuteOwners copyWith({
|
||||
bool? manual,
|
||||
bool? permission,
|
||||
bool? talkPower,
|
||||
}) {
|
||||
return HardMuteOwners(
|
||||
manual: manual ?? this.manual,
|
||||
permission: permission ?? this.permission,
|
||||
talkPower: talkPower ?? this.talkPower,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const iosAudioSessionChannelName = 'chanora/ios_audio_session';
|
||||
|
||||
/// Controls the iOS AVAudioSession VoIP lifecycle from Dart.
|
||||
///
|
||||
/// The Swift `AppDelegate` configures the session to `.ambient` at
|
||||
/// launch and leaves it inactive. The session is only switched to
|
||||
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
|
||||
/// voice channel is actually active. This controller is the Dart
|
||||
/// side of that contract — call [activate] when the Rust engine
|
||||
/// emits `BridgeEvent::AudioStarted` and [deactivate] on
|
||||
/// `BridgeEvent::AudioStopped`.
|
||||
///
|
||||
/// 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();
|
||||
@@ -0,0 +1,533 @@
|
||||
/// macOS permission integration for Input Monitoring, Local Network,
|
||||
/// and Notifications.
|
||||
///
|
||||
/// Trace:
|
||||
/// - SRS-198 (Push-to-talk system permission acquisition).
|
||||
/// - SRS-297 / SRS-300 (Input Monitoring for global PTT on macOS).
|
||||
/// - SysRS-166 (Desktop notifications).
|
||||
/// - SDD-091 (PTT capability badge — live capability level).
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// * Subscribe to the Swift-side `MethodChannel`
|
||||
/// `app.chanora/macos_permissions` for inbound state-change
|
||||
/// invocations emitted by the native handler in
|
||||
/// `MainFlutterWindow.swift` (Swift → Dart).
|
||||
/// * Provide imperative Dart → Swift entry points for checking and
|
||||
/// requesting Input Monitoring, triggering the Local Network
|
||||
/// prompt, and requesting notification authorization.
|
||||
/// * Expose the latest resolved states as [ValueListenable] so UI
|
||||
/// surfaces (PTT capability badge, permission banners, connection
|
||||
/// error messages) can react without polling.
|
||||
///
|
||||
/// ## Non-macOS short-circuit
|
||||
///
|
||||
/// On Android / iOS / Linux / Windows / web, none of these macOS-
|
||||
/// specific permissions exist. The MethodChannel is therefore never
|
||||
/// constructed off-macOS. Each state listenable stays at its default
|
||||
/// "granted" / "not needed" value so all consumers become no-ops.
|
||||
///
|
||||
/// ## No global statics
|
||||
///
|
||||
/// Following the pattern established by `AndroidPermissionsService`
|
||||
/// (SDD-106) and `BackIntentService` (SDD-028), this class is
|
||||
/// constructor-injected. The host app instantiates one instance at
|
||||
/// startup and passes it through the widget tree.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// MethodChannel name shared with Swift `MacOSPermissionsHandler`.
|
||||
@visibleForTesting
|
||||
const String macOSPermissionsChannelName =
|
||||
'app.chanora/macos_permissions';
|
||||
|
||||
// Outbound (Dart → Swift) method names.
|
||||
@visibleForTesting
|
||||
const String methodCheckInputMonitoring = 'checkInputMonitoring';
|
||||
@visibleForTesting
|
||||
const String methodRequestInputMonitoring = 'requestInputMonitoring';
|
||||
@visibleForTesting
|
||||
const String methodOpenInputMonitoringSettings =
|
||||
'openInputMonitoringSettings';
|
||||
@visibleForTesting
|
||||
const String methodTriggerLocalNetworkPrompt = 'triggerLocalNetworkPrompt';
|
||||
@visibleForTesting
|
||||
const String methodCheckLocalNetwork = 'checkLocalNetwork';
|
||||
@visibleForTesting
|
||||
const String methodCheckLocalNetworkAccess = 'checkLocalNetworkAccess';
|
||||
@visibleForTesting
|
||||
const String methodRequestNotifications = 'requestNotifications';
|
||||
@visibleForTesting
|
||||
const String methodCheckNotifications = 'checkNotifications';
|
||||
|
||||
// Inbound (Swift → Dart) method names.
|
||||
@visibleForTesting
|
||||
const String methodInputMonitoringStateChanged =
|
||||
'inputMonitoringStateChanged';
|
||||
@visibleForTesting
|
||||
const String methodLocalNetworkStateChanged = 'localNetworkStateChanged';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Discrete states for macOS-specific permissions.
|
||||
enum MacOSPermissionState {
|
||||
/// Permission granted.
|
||||
granted,
|
||||
|
||||
/// Permission denied by the user.
|
||||
denied,
|
||||
|
||||
/// Permission has not yet been determined (first launch before
|
||||
/// any prompt, or the system returned an unexpected value).
|
||||
notDetermined,
|
||||
|
||||
/// No resolved state yet (cold launch before the first emission,
|
||||
/// or non-macOS host before short-circuit). Consumers treat this
|
||||
/// as "not yet known".
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Local Network permission states, extended to cover macOS 14 and
|
||||
/// earlier where Local Network Privacy does not exist.
|
||||
enum MacOSLocalNetworkState {
|
||||
/// Permission granted or the Local Network prompt was satisfied.
|
||||
granted,
|
||||
|
||||
/// Permission explicitly denied by the user (macOS 15+ only).
|
||||
denied,
|
||||
|
||||
/// No prompt shown yet.
|
||||
notDetermined,
|
||||
|
||||
/// Running on macOS 14 or earlier where Local Network Privacy
|
||||
/// does not apply. Consumers treat this as "granted".
|
||||
unsupported,
|
||||
|
||||
/// No resolved state yet.
|
||||
unknown,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State parsing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
MacOSPermissionState _parsePermissionState(String? raw) {
|
||||
switch (raw) {
|
||||
case 'Granted':
|
||||
return MacOSPermissionState.granted;
|
||||
case 'Denied':
|
||||
return MacOSPermissionState.denied;
|
||||
case 'NotDetermined':
|
||||
return MacOSPermissionState.notDetermined;
|
||||
default:
|
||||
return MacOSPermissionState.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
MacOSLocalNetworkState _parseLocalNetworkState(String? raw) {
|
||||
switch (raw) {
|
||||
case 'Granted':
|
||||
return MacOSLocalNetworkState.granted;
|
||||
case 'Denied':
|
||||
return MacOSLocalNetworkState.denied;
|
||||
case 'NotDetermined':
|
||||
return MacOSLocalNetworkState.notDetermined;
|
||||
case 'Unsupported':
|
||||
return MacOSLocalNetworkState.unsupported;
|
||||
default:
|
||||
return MacOSLocalNetworkState.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PTT capability level mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maps the Input Monitoring state to the PTT capability level string
|
||||
/// consumed by [PttCapabilityBadge].
|
||||
///
|
||||
/// Trace: SDD-091 (capability badge); desktop-ptt-architecture.md
|
||||
/// (macOS Event Tap strategy).
|
||||
String _pttCapabilityLevel(MacOSPermissionState inputMonitoring) {
|
||||
switch (inputMonitoring) {
|
||||
case MacOSPermissionState.granted:
|
||||
return 'L1MacOSEventTap';
|
||||
case MacOSPermissionState.denied:
|
||||
case MacOSPermissionState.notDetermined:
|
||||
case MacOSPermissionState.unknown:
|
||||
return 'L0Focused';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MacOSPermissionsService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Dart-side integration for macOS permission state.
|
||||
///
|
||||
/// Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091.
|
||||
class MacOSPermissionsService {
|
||||
/// Construct a service bound to [channel]. Injected for testability;
|
||||
/// production code uses the default channel keyed on
|
||||
/// [macOSPermissionsChannelName].
|
||||
MacOSPermissionsService({MethodChannel? channel})
|
||||
: _channel = channel ??
|
||||
(_isMacOS
|
||||
? const MethodChannel(macOSPermissionsChannelName)
|
||||
: null);
|
||||
|
||||
/// Platform-detection seam. Web counts as non-macOS.
|
||||
static bool get _isMacOS {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isMacOS;
|
||||
}
|
||||
|
||||
final MethodChannel? _channel;
|
||||
bool _started = false;
|
||||
|
||||
// -- Input Monitoring -----------------------------------------------------
|
||||
|
||||
final ValueNotifier<MacOSPermissionState> _inputMonitoringState =
|
||||
ValueNotifier<MacOSPermissionState>(
|
||||
// Non-macOS: granted so consumers are no-ops.
|
||||
_isMacOS
|
||||
? MacOSPermissionState.unknown
|
||||
: MacOSPermissionState.granted,
|
||||
);
|
||||
|
||||
/// Latest known Input Monitoring permission state.
|
||||
///
|
||||
/// On macOS this drives the PTT capability level: `granted` →
|
||||
/// `L1MacOSEventTap` (global PTT via Event Tap); anything else →
|
||||
/// `L0Focused` (focused-only PTT).
|
||||
ValueListenable<MacOSPermissionState> get inputMonitoringState =>
|
||||
_inputMonitoringState;
|
||||
|
||||
// -- Local Network --------------------------------------------------------
|
||||
|
||||
final ValueNotifier<MacOSLocalNetworkState> _localNetworkState =
|
||||
ValueNotifier<MacOSLocalNetworkState>(
|
||||
_isMacOS
|
||||
? MacOSLocalNetworkState.unknown
|
||||
: MacOSLocalNetworkState.unsupported,
|
||||
);
|
||||
|
||||
/// Latest known Local Network permission state.
|
||||
///
|
||||
/// On macOS 15+ (Sequoia) this reflects the Local Network Privacy
|
||||
/// TCC permission. On macOS 14 and earlier, the value is
|
||||
/// [MacOSLocalNetworkState.unsupported] (no prompt needed).
|
||||
ValueListenable<MacOSLocalNetworkState> get localNetworkState =>
|
||||
_localNetworkState;
|
||||
|
||||
// -- Notifications --------------------------------------------------------
|
||||
|
||||
final ValueNotifier<MacOSPermissionState> _notificationState =
|
||||
ValueNotifier<MacOSPermissionState>(
|
||||
_isMacOS
|
||||
? MacOSPermissionState.unknown
|
||||
: MacOSPermissionState.granted,
|
||||
);
|
||||
|
||||
/// Latest known notification authorization state.
|
||||
ValueListenable<MacOSPermissionState> get notificationState =>
|
||||
_notificationState;
|
||||
|
||||
// -- PTT capability (derived) ---------------------------------------------
|
||||
|
||||
final ValueNotifier<String> _pttCapabilityState =
|
||||
ValueNotifier<String>(
|
||||
_pttCapabilityLevel(
|
||||
_isMacOS ? MacOSPermissionState.unknown : MacOSPermissionState.granted,
|
||||
),
|
||||
);
|
||||
|
||||
/// Derived PTT capability level string, ready for consumption by
|
||||
/// [PttCapabilityBadge]. Updates automatically when Input Monitoring
|
||||
/// state changes.
|
||||
///
|
||||
/// Returns `"L1MacOSEventTap"` when Input Monitoring is granted,
|
||||
/// `"L0Focused"` otherwise.
|
||||
ValueListenable<String> get pttCapabilityState => _pttCapabilityState;
|
||||
|
||||
// -- Lifecycle ------------------------------------------------------------
|
||||
|
||||
/// Start listening for state updates from Swift. Idempotent.
|
||||
///
|
||||
/// On non-macOS this is a no-op.
|
||||
///
|
||||
/// Note: this only registers the inbound handler. Call
|
||||
/// [checkInitialStates] afterward to eagerly query the current
|
||||
/// permission states from the native side.
|
||||
void start() {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
final ch = _channel;
|
||||
if (ch == null) return;
|
||||
ch.setMethodCallHandler(_handle);
|
||||
}
|
||||
|
||||
/// Eagerly query the current permission states from the native
|
||||
/// side. Call this after [start] so the PTT capability badge
|
||||
/// shows the correct level on the first frame.
|
||||
///
|
||||
/// On non-macOS this is a no-op.
|
||||
void checkInitialStates() {
|
||||
final ch = _channel;
|
||||
if (ch == null) return;
|
||||
unawaited(_checkInputMonitoring());
|
||||
unawaited(_checkLocalNetwork());
|
||||
unawaited(_checkNotifications());
|
||||
}
|
||||
|
||||
/// Stop listening. Idempotent.
|
||||
void stop() {
|
||||
if (!_started) return;
|
||||
_started = false;
|
||||
final ch = _channel;
|
||||
if (ch == null) return;
|
||||
ch.setMethodCallHandler(null);
|
||||
}
|
||||
|
||||
// -- Inbound handler (Swift → Dart) --------------------------------------
|
||||
|
||||
Future<dynamic> _handle(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case methodInputMonitoringStateChanged:
|
||||
final args = call.arguments;
|
||||
if (args is Map) {
|
||||
final state = _parsePermissionState(args['state'] as String?);
|
||||
_inputMonitoringState.value = state;
|
||||
_pttCapabilityState.value = _pttCapabilityLevel(state);
|
||||
}
|
||||
break;
|
||||
case methodLocalNetworkStateChanged:
|
||||
final args = call.arguments;
|
||||
if (args is Map) {
|
||||
_localNetworkState.value =
|
||||
_parseLocalNetworkState(args['state'] as String?);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Outbound: Input Monitoring -------------------------------------------
|
||||
|
||||
/// Check the current Input Monitoring permission state without
|
||||
/// triggering a system prompt.
|
||||
Future<MacOSPermissionState> _checkInputMonitoring() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return MacOSPermissionState.granted;
|
||||
try {
|
||||
final raw =
|
||||
await ch.invokeMethod<String>(methodCheckInputMonitoring);
|
||||
final state = _parsePermissionState(raw);
|
||||
_inputMonitoringState.value = state;
|
||||
_pttCapabilityState.value = _pttCapabilityLevel(state);
|
||||
return state;
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'checkInputMonitoring failed',
|
||||
name: 'MacOSPermissionsService',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
return _inputMonitoringState.value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Request Input Monitoring permission. On macOS this calls
|
||||
/// `CGRequestListenEventAccess()` which shows a system dialog
|
||||
/// or opens System Settings (depending on macOS version).
|
||||
///
|
||||
/// Returns the new state after the request.
|
||||
Future<MacOSPermissionState> requestInputMonitoring() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return MacOSPermissionState.granted;
|
||||
try {
|
||||
final raw = await ch.invokeMethod<String>(
|
||||
methodRequestInputMonitoring,
|
||||
);
|
||||
final state = _parsePermissionState(raw);
|
||||
_inputMonitoringState.value = state;
|
||||
_pttCapabilityState.value = _pttCapabilityLevel(state);
|
||||
return state;
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'requestInputMonitoring failed',
|
||||
name: 'MacOSPermissionsService',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
return _inputMonitoringState.value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Open System Settings → Privacy & Security → Input Monitoring
|
||||
/// so the user can manually grant the permission for the
|
||||
/// permanently-denied case (TCC drag-based permission cannot be
|
||||
/// programmatically granted).
|
||||
Future<void> openInputMonitoringSettings() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return;
|
||||
try {
|
||||
await ch.invokeMethod<void>(methodOpenInputMonitoringSettings);
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'openInputMonitoringSettings failed',
|
||||
name: 'MacOSPermissionsService',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Outbound: Local Network ----------------------------------------------
|
||||
|
||||
Future<MacOSLocalNetworkState> _checkLocalNetwork() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return MacOSLocalNetworkState.unsupported;
|
||||
try {
|
||||
final raw = await ch.invokeMethod<String>(methodCheckLocalNetwork);
|
||||
final state = _parseLocalNetworkState(raw);
|
||||
_localNetworkState.value = state;
|
||||
return state;
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'checkLocalNetwork failed',
|
||||
name: 'MacOSPermissionsService',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
return _localNetworkState.value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger the Local Network permission prompt by starting a
|
||||
/// brief `NWBrowser` scan for `_ts3._tcp`. On macOS 15+ this
|
||||
/// shows the system Local Network Privacy dialog. On earlier
|
||||
/// versions, this is a no-op (returns `unsupported`).
|
||||
Future<MacOSLocalNetworkState> triggerLocalNetworkPrompt() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return MacOSLocalNetworkState.unsupported;
|
||||
try {
|
||||
final raw = await ch.invokeMethod<String>(
|
||||
methodTriggerLocalNetworkPrompt,
|
||||
);
|
||||
final state = _parseLocalNetworkState(raw);
|
||||
_localNetworkState.value = state;
|
||||
return state;
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'triggerLocalNetworkPrompt failed',
|
||||
name: 'MacOSPermissionsService',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
return _localNetworkState.value;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final ch = _channel;
|
||||
if (ch == null) return MacOSPermissionState.granted;
|
||||
try {
|
||||
final raw =
|
||||
await ch.invokeMethod<String>(methodCheckNotifications);
|
||||
final state = _parsePermissionState(raw);
|
||||
_notificationState.value = state;
|
||||
return state;
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'checkNotifications failed',
|
||||
name: 'MacOSPermissionsService',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
return _notificationState.value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Request notification authorization via `UNUserNotificationCenter`.
|
||||
/// Returns the new state after the system dialog resolves.
|
||||
Future<MacOSPermissionState> requestNotifications() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return MacOSPermissionState.granted;
|
||||
try {
|
||||
final raw = await ch.invokeMethod<String>(
|
||||
methodRequestNotifications,
|
||||
);
|
||||
final state = _parsePermissionState(raw);
|
||||
_notificationState.value = state;
|
||||
return state;
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'requestNotifications failed',
|
||||
name: 'MacOSPermissionsService',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
return _notificationState.value;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Cleanup --------------------------------------------------------------
|
||||
|
||||
/// Release state notifiers. Test helper; production keeps the
|
||||
/// service alive for the lifetime of the app.
|
||||
@visibleForTesting
|
||||
void dispose() {
|
||||
stop();
|
||||
_inputMonitoringState.dispose();
|
||||
_localNetworkState.dispose();
|
||||
_notificationState.dispose();
|
||||
_pttCapabilityState.dispose();
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,12 @@ Stream<BridgeEvent> eventsStream() =>
|
||||
Future<BridgeAudioStats> audioStats() =>
|
||||
RustLib.instance.api.crateApiAudioStats();
|
||||
|
||||
/// 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 or the session is dropped.
|
||||
Stream<double> inputLevelStream() =>
|
||||
RustLib.instance.api.crateApiInputLevelStream();
|
||||
|
||||
/// Apply the P1 audio-processing config.
|
||||
Future<void> setAudioProcessingConfig({
|
||||
required BridgeAudioProcessingConfig config,
|
||||
@@ -681,15 +687,22 @@ class BridgeAudioStats {
|
||||
/// Current push-to-talk state.
|
||||
final bool pttActive;
|
||||
|
||||
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
|
||||
final double inputLevel;
|
||||
|
||||
const BridgeAudioStats({
|
||||
required this.framesSent,
|
||||
required this.framesReceived,
|
||||
required this.pttActive,
|
||||
required this.inputLevel,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
framesSent.hashCode ^ framesReceived.hashCode ^ pttActive.hashCode;
|
||||
framesSent.hashCode ^
|
||||
framesReceived.hashCode ^
|
||||
pttActive.hashCode ^
|
||||
inputLevel.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -698,7 +711,8 @@ class BridgeAudioStats {
|
||||
runtimeType == other.runtimeType &&
|
||||
framesSent == other.framesSent &&
|
||||
framesReceived == other.framesReceived &&
|
||||
pttActive == other.pttActive;
|
||||
pttActive == other.pttActive &&
|
||||
inputLevel == other.inputLevel;
|
||||
}
|
||||
|
||||
/// Bookmark DTO mirroring [`chanora_core::Bookmark`].
|
||||
|
||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
||||
String get codegenVersion => '2.12.0';
|
||||
|
||||
@override
|
||||
int get rustContentHash => 281698435;
|
||||
int get rustContentHash => -20394775;
|
||||
|
||||
static const kDefaultExternalLibraryLoaderConfig =
|
||||
ExternalLibraryLoaderConfig(
|
||||
@@ -123,6 +123,8 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<void> crateApiInitStorage({required String dir});
|
||||
|
||||
Stream<double> crateApiInputLevelStream();
|
||||
|
||||
Future<bool> crateApiIsConnected();
|
||||
|
||||
Future<BridgeAudioDeviceList> crateApiListAudioDevices();
|
||||
@@ -762,6 +764,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
TaskConstMeta get kCrateApiInitStorageConstMeta =>
|
||||
const TaskConstMeta(debugName: "init_storage", argNames: ["dir"]);
|
||||
|
||||
@override
|
||||
Stream<double> crateApiInputLevelStream() {
|
||||
final sink = RustStreamSink<double>();
|
||||
unawaited(
|
||||
handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_StreamSink_f_32_Sse(sink, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 21,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiInputLevelStreamConstMeta,
|
||||
argValues: [sink],
|
||||
apiImpl: this,
|
||||
),
|
||||
),
|
||||
);
|
||||
return sink.stream;
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiInputLevelStreamConstMeta =>
|
||||
const TaskConstMeta(debugName: "input_level_stream", argNames: ["sink"]);
|
||||
|
||||
@override
|
||||
Future<bool> crateApiIsConnected() {
|
||||
return handler.executeNormal(
|
||||
@@ -771,7 +805,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 21,
|
||||
funcId: 22,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -798,7 +832,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 22,
|
||||
funcId: 23,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -825,7 +859,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 23,
|
||||
funcId: 24,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -849,7 +883,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_String,
|
||||
@@ -879,7 +913,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 25,
|
||||
funcId: 26,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -909,7 +943,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 26,
|
||||
funcId: 27,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -936,7 +970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 27,
|
||||
funcId: 28,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -961,7 +995,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(state, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -994,7 +1028,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 29,
|
||||
funcId: 30,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1021,7 +1055,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_audio_route(route, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -1055,7 +1089,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 31,
|
||||
funcId: 32,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1090,7 +1124,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 32,
|
||||
funcId: 33,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1120,7 +1154,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 33,
|
||||
funcId: 34,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1148,7 +1182,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 34,
|
||||
funcId: 35,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1176,7 +1210,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 35,
|
||||
funcId: 36,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1206,7 +1240,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 36,
|
||||
funcId: 37,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1234,7 +1268,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_network_state(state, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -1260,7 +1294,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 38,
|
||||
funcId: 39,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1288,7 +1322,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 39,
|
||||
funcId: 40,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1316,7 +1350,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 40,
|
||||
funcId: 41,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1344,7 +1378,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 41,
|
||||
funcId: 42,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1376,7 +1410,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 42,
|
||||
funcId: 43,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1406,7 +1440,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 43,
|
||||
funcId: 44,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1434,7 +1468,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 44,
|
||||
funcId: 45,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1462,7 +1496,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 45,
|
||||
funcId: 46,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1489,7 +1523,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 46,
|
||||
funcId: 47,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1517,7 +1551,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 47,
|
||||
funcId: 48,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1549,7 +1583,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 48,
|
||||
funcId: 49,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1578,7 +1612,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 49,
|
||||
funcId: 50,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1610,6 +1644,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@protected
|
||||
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@protected
|
||||
String dco_decode_String(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
@@ -1781,12 +1821,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<dynamic>;
|
||||
if (arr.length != 3)
|
||||
throw Exception('unexpected arr length: expect 3 but see ${arr.length}');
|
||||
if (arr.length != 4)
|
||||
throw Exception('unexpected arr length: expect 4 but see ${arr.length}');
|
||||
return BridgeAudioStats(
|
||||
framesSent: dco_decode_u_32(arr[0]),
|
||||
framesReceived: dco_decode_u_32(arr[1]),
|
||||
pttActive: dco_decode_bool(arr[2]),
|
||||
inputLevel: dco_decode_f_32(arr[3]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2271,6 +2312,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
throw UnimplementedError('Unreachable ()');
|
||||
}
|
||||
|
||||
@protected
|
||||
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
|
||||
SseDeserializer deserializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
throw UnimplementedError('Unreachable ()');
|
||||
}
|
||||
|
||||
@protected
|
||||
String sse_decode_String(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -2488,10 +2537,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
var var_framesSent = sse_decode_u_32(deserializer);
|
||||
var var_framesReceived = sse_decode_u_32(deserializer);
|
||||
var var_pttActive = sse_decode_bool(deserializer);
|
||||
var var_inputLevel = sse_decode_f_32(deserializer);
|
||||
return BridgeAudioStats(
|
||||
framesSent: var_framesSent,
|
||||
framesReceived: var_framesReceived,
|
||||
pttActive: var_pttActive,
|
||||
inputLevel: var_inputLevel,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3199,6 +3250,23 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_StreamSink_f_32_Sse(
|
||||
RustStreamSink<double> self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_String(
|
||||
self.setupAndSerialize(
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_f_32,
|
||||
decodeErrorData: sse_decode_AnyhowException,
|
||||
),
|
||||
),
|
||||
serializer,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_String(String self, SseSerializer serializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -3380,6 +3448,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
sse_encode_u_32(self.framesSent, serializer);
|
||||
sse_encode_u_32(self.framesReceived, serializer);
|
||||
sse_encode_bool(self.pttActive, serializer);
|
||||
sse_encode_f_32(self.inputLevel, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
|
||||
@@ -27,6 +27,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw);
|
||||
|
||||
@protected
|
||||
String dco_decode_String(dynamic raw);
|
||||
|
||||
@@ -210,6 +213,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
String sse_decode_String(SseDeserializer deserializer);
|
||||
|
||||
@@ -439,6 +447,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_StreamSink_f_32_Sse(
|
||||
RustStreamSink<double> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_String(String self, SseSerializer serializer);
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw);
|
||||
|
||||
@protected
|
||||
String dco_decode_String(dynamic raw);
|
||||
|
||||
@@ -212,6 +215,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
String sse_decode_String(SseDeserializer deserializer);
|
||||
|
||||
@@ -441,6 +449,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_StreamSink_f_32_Sse(
|
||||
RustStreamSink<double> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_String(String self, SseSerializer serializer);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/breakpoints.dart';
|
||||
|
||||
/// Semantic tones for lightweight, Material 3 SnackBars.
|
||||
enum AppSnackBarVariant { neutral, success, warning, error }
|
||||
|
||||
@@ -7,7 +9,6 @@ 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;
|
||||
|
||||
@@ -39,9 +40,9 @@ class AppSnackBar {
|
||||
}) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final viewWidth = MediaQuery.sizeOf(context).width;
|
||||
final useDesktopCap = viewWidth >= 600;
|
||||
final useDesktopCap = viewWidth >= ChanoraBreakpoints.medium;
|
||||
final snackBarWidth = useDesktopCap && margin == null
|
||||
? _desktopMaxWidth
|
||||
? ChanoraBreakpoints.snackBarDesktopCap
|
||||
: null;
|
||||
final effectiveMargin = useDesktopCap && margin != null
|
||||
? _desktopCappedMargin(context, margin)
|
||||
@@ -76,7 +77,11 @@ class AppSnackBar {
|
||||
final viewWidth = MediaQuery.sizeOf(context).width;
|
||||
final resolved = margin.resolve(Directionality.of(context));
|
||||
final extraHorizontal =
|
||||
(viewWidth - _desktopMaxWidth).clamp(0.0, viewWidth) / 2;
|
||||
(viewWidth - ChanoraBreakpoints.snackBarDesktopCap).clamp(
|
||||
0.0,
|
||||
viewWidth,
|
||||
) /
|
||||
2;
|
||||
return EdgeInsets.fromLTRB(
|
||||
resolved.left + extraHorizontal,
|
||||
resolved.top,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// 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,6 +2,7 @@ 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';
|
||||
@@ -13,7 +14,6 @@ import 'bbcode_text.dart';
|
||||
const double _chatSidebarTileExtent = 92;
|
||||
const double _chatSidebarCompactTileExtent = 76;
|
||||
const double _chatSidebarCompactHeight = 84;
|
||||
const double _chatMobileBreakpoint = 600;
|
||||
|
||||
/// One chat/activity message shown in the chat hub.
|
||||
class ChatEntry {
|
||||
@@ -616,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,
|
||||
@@ -641,7 +641,7 @@ class _ChatPageState extends State<ChatPage> {
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final sidebar = _ChatSidebar(
|
||||
compact: constraints.maxWidth < _chatMobileBreakpoint,
|
||||
compact: constraints.maxWidth < ChanoraBreakpoints.medium,
|
||||
selectedTarget: _selectedTarget,
|
||||
privateChats: _privateChats,
|
||||
onSelect: _selectTarget,
|
||||
@@ -650,7 +650,7 @@ class _ChatPageState extends State<ChatPage> {
|
||||
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
|
||||
}),
|
||||
);
|
||||
if (constraints.maxWidth < _chatMobileBreakpoint) {
|
||||
if (constraints.maxWidth < ChanoraBreakpoints.medium) {
|
||||
return Column(
|
||||
children: [
|
||||
sidebar,
|
||||
@@ -1050,8 +1050,11 @@ class _ChannelGroup extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatDetailView extends StatefulWidget {
|
||||
const _ChatDetailView({
|
||||
/// 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,
|
||||
required this.target,
|
||||
required this.clientName,
|
||||
required this.snapshot,
|
||||
@@ -1059,21 +1062,50 @@ class _ChatDetailView extends StatefulWidget {
|
||||
required this.currentChannelId,
|
||||
required this.channelName,
|
||||
this.onTs3ServerLink,
|
||||
this.headerTrailing,
|
||||
this.messageMaxWidth,
|
||||
this.restoredDraft,
|
||||
this.onDraftChanged,
|
||||
});
|
||||
|
||||
/// 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;
|
||||
|
||||
@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;
|
||||
@@ -1100,8 +1132,35 @@ 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();
|
||||
@@ -1176,7 +1235,12 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Text(_title, style: theme.textTheme.titleMedium),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(_title, style: theme.textTheme.titleMedium)),
|
||||
if (widget.headerTrailing != null) widget.headerTrailing!,
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: msgs.isEmpty
|
||||
@@ -1216,9 +1280,16 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
controller: _scrollCtl,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: msgs.length,
|
||||
itemBuilder: (_, i) => _MessageBubble(
|
||||
entry: msgs[i],
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
itemBuilder: (_, i) => Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: widget.messageMaxWidth ?? double.infinity,
|
||||
),
|
||||
child: _MessageBubble(
|
||||
entry: msgs[i],
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -114,7 +115,6 @@ 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,7 +125,8 @@ class _ConnectFormState extends State<ConnectForm> {
|
||||
label: Text(l10n.bookmarkAddAction),
|
||||
onPressed: widget.onAddBookmark,
|
||||
);
|
||||
if (constraints.maxWidth <= stackedActionsMaxWidth) {
|
||||
if (constraints.maxWidth <=
|
||||
ChanoraBreakpoints.connectActionsStackMaxWidth) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
||||
@@ -23,12 +23,14 @@ 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,
|
||||
});
|
||||
|
||||
@@ -56,6 +58,9 @@ 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;
|
||||
|
||||
@@ -74,6 +79,9 @@ 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;
|
||||
|
||||
@@ -240,47 +248,64 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1082,3 +1107,60 @@ 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ class VoiceBar extends StatelessWidget {
|
||||
required this.onConfigure,
|
||||
required this.onPttHeldChanged,
|
||||
this.talkPowerBlocked = false,
|
||||
this.inputLevel,
|
||||
});
|
||||
|
||||
final bool inChannel;
|
||||
@@ -53,6 +54,10 @@ class VoiceBar extends StatelessWidget {
|
||||
/// level meter. Pass `null` to render an idle meter.
|
||||
final rust.BridgeAudioStats? audioStats;
|
||||
|
||||
/// Real-time input level from the 30 Hz stream (dBFS).
|
||||
/// When non-null, takes precedence over `audioStats.inputLevel`.
|
||||
final double? inputLevel;
|
||||
|
||||
/// PTT capability badge inputs — passed through to
|
||||
/// [`PttCapabilityBadge`].
|
||||
final String pttLevel;
|
||||
@@ -196,7 +201,7 @@ class VoiceBar extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Row 4: level meter
|
||||
VoiceLevelMeter(active: levelActive),
|
||||
VoiceLevelMeter(active: levelActive, level: inputLevel ?? stats?.inputLevel),
|
||||
const SizedBox(height: 4),
|
||||
if (stats != null)
|
||||
Text(
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// release-tail are surfaced inline (radio buttons + slider) inside
|
||||
// the modal.
|
||||
|
||||
import 'dart:async' show Timer, unawaited;
|
||||
import 'dart:async' show StreamSubscription, Timer, unawaited;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
@@ -53,8 +53,11 @@ class VoiceStatusChip extends StatelessWidget {
|
||||
required this.audioStats,
|
||||
required this.isTouchOnly,
|
||||
required this.onTap,
|
||||
required this.onToggleInputMute,
|
||||
required this.onToggleOutputMute,
|
||||
this.inputMuted = false,
|
||||
this.outputMuted = false,
|
||||
this.hardMuteByTalkPower = false,
|
||||
this.talkPower,
|
||||
this.neededTalkPower,
|
||||
this.talkPowerGranted,
|
||||
@@ -81,6 +84,9 @@ class VoiceStatusChip extends StatelessWidget {
|
||||
/// True when local speaker is muted.
|
||||
final bool outputMuted;
|
||||
|
||||
/// True when the server talk-power gate forces local hard mute.
|
||||
final bool hardMuteByTalkPower;
|
||||
|
||||
/// Own client's talk power.
|
||||
final int? talkPower;
|
||||
|
||||
@@ -93,6 +99,12 @@ class VoiceStatusChip extends StatelessWidget {
|
||||
/// Open the voice details modal.
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Toggle local input hard mute.
|
||||
final VoidCallback onToggleInputMute;
|
||||
|
||||
/// Toggle local output mute/deafen.
|
||||
final VoidCallback onToggleOutputMute;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -113,49 +125,48 @@ class VoiceStatusChip extends StatelessWidget {
|
||||
);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}',
|
||||
hint: l10n.voiceSettingsTitle,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onTap();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: ExcludeSemantics(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: summary.talkPowerBlocked
|
||||
? Colors.amber.withValues(alpha: 0.18)
|
||||
: summary.muted
|
||||
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
|
||||
: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: summary.talkPowerBlocked
|
||||
? Colors.amber.shade700
|
||||
: summary.muted
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.outlineVariant,
|
||||
width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: summary.talkPowerBlocked
|
||||
? Colors.amber.withValues(alpha: 0.18)
|
||||
: summary.muted
|
||||
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
|
||||
: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: summary.talkPowerBlocked
|
||||
? Colors.amber.shade700
|
||||
: summary.muted
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.outlineVariant,
|
||||
width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
summary.micOn
|
||||
? Icons.fiber_manual_record
|
||||
: Icons.fiber_manual_record_outlined,
|
||||
size: 12,
|
||||
color: summary.micOn
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outline,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
summary.micOn
|
||||
? Icons.fiber_manual_record
|
||||
: Icons.fiber_manual_record_outlined,
|
||||
size: 12,
|
||||
color: summary.micOn
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onTap();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -179,15 +190,60 @@ class VoiceStatusChip extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.expand_less,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: hardMuteByTalkPower
|
||||
? l10n.voiceTalkPowerBlocked
|
||||
: l10n.voiceHardMuteLabel,
|
||||
icon: Icon(inputMuted ? Icons.mic_off : Icons.mic),
|
||||
color: inputMuted ? theme.colorScheme.error : null,
|
||||
onPressed: hardMuteByTalkPower ? null : onToggleInputMute,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: 40,
|
||||
height: 40,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
style: const ButtonStyle(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.voiceOutputMuteLabel,
|
||||
icon: Icon(outputMuted ? Icons.headset_off : Icons.headset),
|
||||
color: outputMuted ? theme.colorScheme.error : null,
|
||||
onPressed: onToggleOutputMute,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: 40,
|
||||
height: 40,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
style: const ButtonStyle(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.voiceSettingsTitle,
|
||||
icon: Icon(
|
||||
Icons.expand_less,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onPressed: onTap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: 40,
|
||||
height: 40,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
style: const ButtonStyle(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -262,6 +318,15 @@ 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);
|
||||
@@ -455,6 +520,8 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
int _rateTickCount = 0;
|
||||
|
||||
late final AudioProcessingConfigState _audioProcessing;
|
||||
double? _streamLevel;
|
||||
StreamSubscription<double>? _levelSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -463,8 +530,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
widget.initialAudioConfig,
|
||||
);
|
||||
|
||||
// Poll audio stats at 250 ms so TX/RX counters and the level meter
|
||||
// update in real time while the sheet is open, independent of the parent.
|
||||
_levelSub = rust.inputLevelStream().listen((level) {
|
||||
if (mounted) setState(() => _streamLevel = level);
|
||||
});
|
||||
|
||||
// Poll audio stats at 250 ms so TX/RX counters update in real time
|
||||
// while the sheet is open.
|
||||
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
|
||||
try {
|
||||
final s = await rust.audioStats();
|
||||
@@ -472,7 +543,6 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
setState(() {
|
||||
_stats = s;
|
||||
_rateTickCount++;
|
||||
// Compute rates every ~1 s (4 × 250 ms).
|
||||
if (_rateTickCount >= 4) {
|
||||
_txRate = s.framesSent - _prevSent;
|
||||
_rxRate = s.framesReceived - _prevReceived;
|
||||
@@ -487,6 +557,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_levelSub?.cancel();
|
||||
_statsTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -625,7 +696,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 4) Level meter + live TX/RX stats.
|
||||
VoiceLevelMeter(active: levelActive),
|
||||
VoiceLevelMeter(active: levelActive, level: _streamLevel ?? stats?.inputLevel),
|
||||
const SizedBox(height: 6),
|
||||
_StatsRow(
|
||||
txRate: _txRate,
|
||||
|
||||
@@ -1,28 +1,78 @@
|
||||
import 'dart:math' show max;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared compact level meter used by voice surfaces.
|
||||
class VoiceLevelMeter extends StatelessWidget {
|
||||
const VoiceLevelMeter({super.key, required this.active});
|
||||
///
|
||||
/// When [level] is null (no stats available yet), falls back to [active]
|
||||
/// for a binary indicator. When [level] is provided it is interpreted as
|
||||
/// dBFS and mapped to a 0–1 fill fraction via [dbfsToFraction] (floors
|
||||
/// at -60 dBFS).
|
||||
class VoiceLevelMeter extends StatefulWidget {
|
||||
const VoiceLevelMeter({super.key, this.active = false, this.level});
|
||||
|
||||
/// Binary fallback when no dBFS value is available.
|
||||
final bool active;
|
||||
|
||||
/// Real input level in dBFS (-120 = silence, 0 = clipping).
|
||||
/// Null means stats are not yet available; [active] is used instead.
|
||||
final double? level;
|
||||
|
||||
/// Map dBFS [-60, 0] → [0.0, 1.0].
|
||||
static double dbfsToFraction(double dbfs) {
|
||||
const floor = -60.0;
|
||||
if (dbfs <= floor) return 0.0;
|
||||
if (dbfs >= 0.0) return 1.0;
|
||||
return (dbfs - floor) / -floor;
|
||||
}
|
||||
|
||||
@override
|
||||
State<VoiceLevelMeter> createState() => _VoiceLevelMeterState();
|
||||
}
|
||||
|
||||
class _VoiceLevelMeterState extends State<VoiceLevelMeter> {
|
||||
double _previousFill = 0.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final double fill;
|
||||
final Color color;
|
||||
if (widget.level != null) {
|
||||
fill = VoiceLevelMeter.dbfsToFraction(widget.level!);
|
||||
color = fill > 0.0
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant;
|
||||
} else {
|
||||
fill = widget.active ? 0.75 : 0.05;
|
||||
color = widget.active
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant;
|
||||
}
|
||||
|
||||
final begin = _previousFill;
|
||||
_previousFill = fill;
|
||||
|
||||
return Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
widthFactor: active ? 0.75 : 0.05,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(begin: begin, end: fill),
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, animatedFill, child) {
|
||||
return FractionallySizedBox(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
widthFactor: max(animatedFill, 0.02),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant,
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
#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,2 +1,11 @@
|
||||
#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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Versions/Current/Resources
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key><string>chanora_bridge</string>
|
||||
<key>CFBundleIdentifier</key><string>app.chanora.bridge</string>
|
||||
<key>CFBundleName</key><string>chanora_bridge</string>
|
||||
<key>CFBundlePackageType</key><string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key><string>1.0.0</string>
|
||||
<key>CFBundleVersion</key><string>1</string>
|
||||
<key>CFBundleSupportedPlatforms</key><array><string>MacOSX</string></array>
|
||||
<key>MinimumOSVersion</key><string>10.15</string>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
Binary file not shown.
@@ -1 +0,0 @@
|
||||
A
|
||||
@@ -1 +0,0 @@
|
||||
Versions/Current/chanora_bridge
|
||||
@@ -1,57 +1,20 @@
|
||||
PODS:
|
||||
- audio_session (0.0.1):
|
||||
- FlutterMacOS
|
||||
- chanora_bridge (1.0.0)
|
||||
- connectivity_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- package_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- share_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
||||
- chanora_bridge (from `/Users/edison/dev/chanora/apps/chanora_flutter/macos`)
|
||||
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
||||
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_session:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
||||
chanora_bridge:
|
||||
:path: "/Users/edison/dev/chanora/apps/chanora_flutter/macos"
|
||||
connectivity_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
package_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
|
||||
share_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
|
||||
shared_preferences_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
||||
url_launcher_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
|
||||
chanora_bridge: 4105993843b5421ee4ce72220a74c63f6fd99103
|
||||
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
|
||||
chanora_bridge: 9d1469952801a1caa3bb56d5d3bce91df8dca4ad
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||
|
||||
PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
45F255D1DE0134185DB5423D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 06E1AA7E1FB968C1D78DA8DE /* PrivacyInfo.xcprivacy */; };
|
||||
9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */; };
|
||||
C2DC22E19FDCE26B9D79442E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -93,6 +94,7 @@
|
||||
ED6F5EE0C5FD4DA22D79188C /* 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>"; };
|
||||
EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -108,6 +110,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
8C6000042DD0000000000001 /* SileroCoreML in Frameworks */,
|
||||
9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
@@ -170,6 +173,7 @@
|
||||
33CEB47122A05771004F2AC0 /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
|
||||
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
|
||||
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
|
||||
@@ -244,6 +248,7 @@
|
||||
4287874B577AE59BBE39386D /* [CP] Check Pods Manifest.lock */,
|
||||
33CC10E92044A3C60003C045 /* Sources */,
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||
CA110002000000000000A200 /* Verify Silero Exports */,
|
||||
33CC10EB2044A3C60003C045 /* Resources */,
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||
@@ -256,6 +261,7 @@
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
8C6000032DD0000000000001 /* SileroCoreML */,
|
||||
);
|
||||
productName = Runner;
|
||||
@@ -302,6 +308,7 @@
|
||||
);
|
||||
mainGroup = 33CC10E42044A3C60003C045;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
|
||||
);
|
||||
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
||||
@@ -435,6 +442,21 @@
|
||||
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 */
|
||||
@@ -865,6 +887,10 @@
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = ../../../silero-coreml;
|
||||
};
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
@@ -873,6 +899,10 @@
|
||||
package = 8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
|
||||
productName = SileroCoreML;
|
||||
};
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = ""$FLUTTER_ROOT"/packages/flutter_tools/bin/macos_assemble.sh prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "chanora_flutter.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
||||
@@ -7,6 +7,10 @@ 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,8 +2,10 @@
|
||||
<!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 + server
|
||||
listener for `flutter run` hot-reload. -->
|
||||
<!-- 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). -->
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
<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>
|
||||
@@ -40,5 +42,9 @@
|
||||
<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>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_ts3._tcp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,387 @@
|
||||
import Cocoa
|
||||
import FlutterMacOS
|
||||
import CoreGraphics
|
||||
import Network
|
||||
import UserNotifications
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MacOSPermissionsHandler
|
||||
//
|
||||
// Native-side MethodChannel handler for macOS-specific permissions
|
||||
// that the Flutter `permission_handler` plugin does not cover:
|
||||
//
|
||||
// • Input Monitoring (CGPreflightListenEventAccess /
|
||||
// CGRequestListenEventAccess) for global PTT.
|
||||
// • Local Network (NWBrowser trigger for _ts3._tcp) so macOS 15+
|
||||
// shows the Local Network Privacy prompt.
|
||||
// • Notifications (UNUserNotificationCenter authorization).
|
||||
//
|
||||
// Channel name: `app.chanora/macos_permissions`
|
||||
//
|
||||
// Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Polling interval for Input Monitoring state changes.
|
||||
/// TCC does not emit a callback when the user toggles Input Monitoring
|
||||
/// in System Settings, so we poll at a reasonable cadence.
|
||||
private let kInputMonitoringPollInterval: TimeInterval = 2.0
|
||||
|
||||
class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
||||
private var channel: FlutterMethodChannel?
|
||||
private var inputMonitoringTimer: Timer?
|
||||
private var lastInputMonitoringState: String = "Unknown"
|
||||
|
||||
// -- FlutterPlugin -------------------------------------------------------
|
||||
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "app.chanora/macos_permissions",
|
||||
binaryMessenger: registrar.messenger
|
||||
)
|
||||
let instance = MacOSPermissionsHandler()
|
||||
instance.channel = channel
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
// -- Input Monitoring ---------------------------------------------------
|
||||
case "checkInputMonitoring":
|
||||
result(inputMonitoringStateString())
|
||||
startInputMonitoringPolling()
|
||||
|
||||
case "requestInputMonitoring":
|
||||
requestInputMonitoring(result: result)
|
||||
|
||||
case "openInputMonitoringSettings":
|
||||
openInputMonitoringSettings(result: result)
|
||||
|
||||
// -- Local Network ------------------------------------------------------
|
||||
case "checkLocalNetwork":
|
||||
checkLocalNetwork(result: result)
|
||||
|
||||
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)
|
||||
|
||||
case "requestNotifications":
|
||||
requestNotifications(result: result)
|
||||
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Input Monitoring
|
||||
// =========================================================================
|
||||
|
||||
/// Returns the current Input Monitoring state as a string
|
||||
/// consumable by the Dart side: "Granted", "Denied", "NotDetermined".
|
||||
private func inputMonitoringStateString() -> String {
|
||||
// CGPreflightListenEventAccess returns true when access is already
|
||||
// granted. On macOS 10.15+ it returns false when denied or not yet
|
||||
// determined — we cannot distinguish those two without attempting
|
||||
// CGRequestListenEventAccess, so we conservatively report
|
||||
// "NotDetermined" when preflight returns false. The Dart side
|
||||
// treats both "Denied" and "NotDetermined" as L0Focused.
|
||||
if CGPreflightListenEventAccess() {
|
||||
return "Granted"
|
||||
}
|
||||
return "NotDetermined"
|
||||
}
|
||||
|
||||
/// Request Input Monitoring permission via
|
||||
/// `CGRequestListenEventAccess()`. On macOS 13+ this opens
|
||||
/// System Settings → Privacy & Security → Input Monitoring.
|
||||
private func requestInputMonitoring(result: @escaping FlutterResult) {
|
||||
// CGRequestListenEventAccess shows the system prompt.
|
||||
// It returns true if access was already granted or becomes
|
||||
// granted synchronously (rare). Most of the time it returns
|
||||
// false and the user must toggle the switch manually.
|
||||
let granted = CGRequestListenEventAccess()
|
||||
let state = granted ? "Granted" : "NotDetermined"
|
||||
lastInputMonitoringState = state
|
||||
result(state)
|
||||
|
||||
// Start polling so we detect when the user grants in Settings.
|
||||
startInputMonitoringPolling()
|
||||
}
|
||||
|
||||
/// Open System Settings → Privacy & Security → Input Monitoring
|
||||
/// so the user can manually enable the app.
|
||||
private func openInputMonitoringSettings(result: @escaping FlutterResult) {
|
||||
if let url = URL(
|
||||
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"
|
||||
) {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
result(nil)
|
||||
}
|
||||
|
||||
/// Start a periodic timer that checks Input Monitoring state and
|
||||
/// notifies the Dart side when it changes.
|
||||
private func startInputMonitoringPolling() {
|
||||
// Don't start a second timer if one is already running.
|
||||
guard inputMonitoringTimer == nil else { return }
|
||||
lastInputMonitoringState = inputMonitoringStateString()
|
||||
|
||||
inputMonitoringTimer = Timer.scheduledTimer(
|
||||
withTimeInterval: kInputMonitoringPollInterval,
|
||||
repeats: true
|
||||
) { [weak self] _ in
|
||||
self?.pollInputMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
private func pollInputMonitoring() {
|
||||
let current = inputMonitoringStateString()
|
||||
guard current != lastInputMonitoringState else { return }
|
||||
lastInputMonitoringState = current
|
||||
|
||||
// Notify the Dart side via the inbound method.
|
||||
channel?.invokeMethod("inputMonitoringStateChanged", arguments: [
|
||||
"state": current,
|
||||
])
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Local Network
|
||||
// =========================================================================
|
||||
|
||||
/// Check whether Local Network access is available.
|
||||
/// On macOS 14 and earlier, Local Network Privacy does not exist,
|
||||
/// so we report "Unsupported". On macOS 15+, we attempt a brief
|
||||
/// NWBrowser scan and report based on the result.
|
||||
private func checkLocalNetwork(result: @escaping FlutterResult) {
|
||||
if #available(macOS 15.0, *) {
|
||||
// We cannot synchronously determine the Local Network state
|
||||
// without actually using the network. Report "NotDetermined"
|
||||
// and let triggerLocalNetworkPrompt resolve the actual state.
|
||||
result("NotDetermined")
|
||||
} else {
|
||||
result("Unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger the Local Network Privacy prompt by starting a brief
|
||||
/// NWBrowser for `_ts3._tcp`. On macOS 15+, this causes the system
|
||||
/// to show the Local Network permission dialog if not already
|
||||
/// determined.
|
||||
///
|
||||
/// The browser is started and stopped after a short scan window.
|
||||
/// State changes are reported back to Dart via
|
||||
/// `localNetworkStateChanged`.
|
||||
@available(macOS 15.0, *)
|
||||
private func triggerLocalNetworkPromptImpl(result: @escaping FlutterResult) {
|
||||
let bonjourType = "_ts3._tcp"
|
||||
let browserDescriptor = NWBrowser.Descriptor.bonjourWithTXTRecord(
|
||||
type: bonjourType, domain: nil)
|
||||
let browser = NWBrowser(for: browserDescriptor, using: NWParameters.tcp)
|
||||
|
||||
var resolved = false
|
||||
|
||||
browser.stateUpdateHandler = { [weak self] (browserState: NWBrowser.State) in
|
||||
switch browserState {
|
||||
case .ready:
|
||||
// Browser started successfully — local network is accessible.
|
||||
if !resolved {
|
||||
resolved = true
|
||||
result("Granted")
|
||||
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
|
||||
"state": "Granted",
|
||||
])
|
||||
}
|
||||
browser.cancel()
|
||||
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) {
|
||||
result("Denied")
|
||||
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
|
||||
"state": "Denied",
|
||||
])
|
||||
} else {
|
||||
// Network unreachable or other transient error —
|
||||
// don't assume denied.
|
||||
result("NotDetermined")
|
||||
}
|
||||
}
|
||||
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 .setup, .cancelled:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
browser.start(queue: DispatchQueue.main)
|
||||
|
||||
// Timeout: if the browser doesn't resolve within 10 seconds,
|
||||
// report NotDetermined so the Dart side doesn't hang forever.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) {
|
||||
if !resolved {
|
||||
resolved = true
|
||||
result("NotDetermined")
|
||||
browser.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerLocalNetworkPrompt(result: @escaping FlutterResult) {
|
||||
if #available(macOS 15.0, *) {
|
||||
triggerLocalNetworkPromptImpl(result: result)
|
||||
} else {
|
||||
result("Unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
// =========================================================================
|
||||
|
||||
private func checkNotifications(result: @escaping FlutterResult) {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized, .provisional:
|
||||
result("Granted")
|
||||
case .denied:
|
||||
result("Denied")
|
||||
case .notDetermined:
|
||||
result("NotDetermined")
|
||||
@unknown default:
|
||||
result("NotDetermined")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestNotifications(result: @escaping FlutterResult) {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [
|
||||
.alert, .sound, .badge,
|
||||
]) { granted, _ in
|
||||
let state = granted ? "Granted" : "Denied"
|
||||
result(state)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Cleanup
|
||||
// =========================================================================
|
||||
|
||||
deinit {
|
||||
inputMonitoringTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MainFlutterWindow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class MainFlutterWindow: NSWindow {
|
||||
override func awakeFromNib() {
|
||||
@@ -15,6 +397,11 @@ class MainFlutterWindow: NSWindow {
|
||||
|
||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||
|
||||
// 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,12 +2,26 @@
|
||||
<!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 the cs.allow-jit + network.server entitlements
|
||||
used only by Flutter's debug hot-reload. -->
|
||||
<!-- 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. -->
|
||||
<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,4 +1,5 @@
|
||||
import CoreML
|
||||
import Darwin
|
||||
import Foundation
|
||||
import SileroCoreML
|
||||
|
||||
@@ -96,3 +97,136 @@ 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,19 +52,25 @@ Pod::Spec.new do |s|
|
||||
|
||||
echo "[chanora_bridge.podspec] cargo build aarch64-apple-darwin"
|
||||
cd "$REPO_ROOT"
|
||||
PATH="$HOME/.cargo/bin:$PATH" \\
|
||||
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/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:$PATH" \\
|
||||
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/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
|
||||
@@ -112,7 +118,19 @@ PLIST
|
||||
|
||||
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \\
|
||||
"$FW/Versions/A/chanora_bridge"
|
||||
echo "[chanora_bridge.podspec] framework ready at $FW"
|
||||
|
||||
# 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"
|
||||
SCRIPT
|
||||
|
||||
# Pod CocoaPods picks this up; the framework gets embedded into
|
||||
@@ -141,44 +159,54 @@ PLIST
|
||||
|
||||
echo "[chanora_bridge script_phase] cargo build aarch64-apple-darwin"
|
||||
cd "$REPO_ROOT"
|
||||
PATH="$HOME/.cargo/bin:$PATH" \
|
||||
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/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:$PATH" \
|
||||
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/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).
|
||||
# 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.
|
||||
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"
|
||||
exit 0
|
||||
FW_UP_TO_DATE=1
|
||||
fi
|
||||
|
||||
# Create universal binary with lipo.
|
||||
mkdir -p "$(dirname "$UNIVERSAL")"
|
||||
lipo -create "$BRIDGE_ARM64" "$BRIDGE_X86_64" -output "$UNIVERSAL"
|
||||
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"
|
||||
|
||||
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">
|
||||
@@ -195,9 +223,24 @@ PLIST
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \
|
||||
"$FW/Versions/A/chanora_bridge"
|
||||
echo "[chanora_bridge script_phase] framework refreshed"
|
||||
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
|
||||
SCRIPT
|
||||
:execution_position => :before_compile,
|
||||
}
|
||||
|
||||
@@ -457,10 +457,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -790,10 +790,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
#!/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 platform route names', () {
|
||||
test('parseBridgeAudioRoute maps iOS-classified route strings', () {
|
||||
expect(parseBridgeAudioRoute('Earpiece'), rust.BridgeAudioRoute.earpiece);
|
||||
expect(parseBridgeAudioRoute('Speaker'), rust.BridgeAudioRoute.speaker);
|
||||
expect(
|
||||
@@ -22,4 +22,34 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/hard_mute_owners.dart';
|
||||
|
||||
void main() {
|
||||
group('HardMuteOwners', () {
|
||||
test('manual mute survives talk-power block and restore', () {
|
||||
const owners = HardMuteOwners(manual: true);
|
||||
|
||||
final blocked = owners.copyWith(talkPower: true);
|
||||
expect(blocked.effective, isTrue);
|
||||
|
||||
final restored = blocked.copyWith(talkPower: false);
|
||||
expect(restored.manual, isTrue);
|
||||
expect(restored.talkPower, isFalse);
|
||||
expect(restored.effective, isTrue);
|
||||
});
|
||||
|
||||
test('effective mute is the union of independent owners', () {
|
||||
expect(const HardMuteOwners().effective, isFalse);
|
||||
expect(const HardMuteOwners(manual: true).effective, isTrue);
|
||||
expect(const HardMuteOwners(permission: true).effective, isTrue);
|
||||
expect(const HardMuteOwners(talkPower: true).effective, isTrue);
|
||||
});
|
||||
|
||||
test('bridge mute does not convert talk-power owner into manual owner', () {
|
||||
const owners = HardMuteOwners(talkPower: true);
|
||||
|
||||
final synced = owners.withBridgeManualMute(true);
|
||||
|
||||
expect(synced.manual, isFalse);
|
||||
expect(synced.talkPower, isTrue);
|
||||
expect(synced.effective, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/ios_audio_session_controller.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('IosAudioSessionController', () {
|
||||
const channel = MethodChannel(iosAudioSessionChannelName);
|
||||
final messenger = TestDefaultBinaryMessengerBinding
|
||||
.instance.defaultBinaryMessenger;
|
||||
|
||||
tearDown(() {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test('channel name matches Swift contract', () {
|
||||
expect(iosAudioSessionChannelName, 'chanora/ios_audio_session');
|
||||
});
|
||||
|
||||
test('activate invokes activateVoiceSession on iOS', () async {
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return null;
|
||||
});
|
||||
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: true,
|
||||
);
|
||||
await controller.activate();
|
||||
|
||||
expect(calls.map((c) => c.method), ['activateVoiceSession']);
|
||||
expect(calls.single.arguments, isNull);
|
||||
});
|
||||
|
||||
test('deactivate invokes deactivateVoiceSession on iOS', () async {
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return null;
|
||||
});
|
||||
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: true,
|
||||
);
|
||||
await controller.deactivate();
|
||||
|
||||
expect(calls.map((c) => c.method), ['deactivateVoiceSession']);
|
||||
expect(calls.single.arguments, isNull);
|
||||
});
|
||||
|
||||
test('activate is a no-op on non-iOS platforms', () async {
|
||||
var invoked = false;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
invoked = true;
|
||||
return null;
|
||||
});
|
||||
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: false,
|
||||
);
|
||||
await controller.activate();
|
||||
await controller.deactivate();
|
||||
|
||||
expect(invoked, isFalse);
|
||||
});
|
||||
|
||||
test('activate swallows PlatformException so engine keeps running',
|
||||
() async {
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
throw PlatformException(code: 'avaudiosession_failed');
|
||||
});
|
||||
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: true,
|
||||
);
|
||||
|
||||
await expectLater(controller.activate(), completes);
|
||||
await expectLater(controller.deactivate(), completes);
|
||||
});
|
||||
|
||||
test('activate swallows MissingPluginException when channel is absent',
|
||||
() async {
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: true,
|
||||
);
|
||||
|
||||
await expectLater(controller.activate(), completes);
|
||||
await expectLater(controller.deactivate(), completes);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
// SWE.4 unit tests for MacOSPermissionsService — the Dart-side
|
||||
// integration layer for the Swift `MacOSPermissionsHandler`
|
||||
// (SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091).
|
||||
//
|
||||
// Requirement trace:
|
||||
// Verification-plan row: SWE4-UV-XXX.
|
||||
// SRS-198 (Push-to-talk system permission acquisition).
|
||||
// SRS-297 / SRS-300 (Input Monitoring for global PTT on macOS).
|
||||
// SysRS-166 (Desktop notifications).
|
||||
// SDD-091 (PTT capability badge — live capability level).
|
||||
//
|
||||
// Strategy: Following the pattern in
|
||||
// `android_permissions_service_test.dart`, use
|
||||
// `TestDefaultBinaryMessengerBinding` to (a) capture outbound
|
||||
// method invocations and (b) inject inbound state-change calls as
|
||||
// if Swift had emitted them.
|
||||
//
|
||||
// Platform note: these tests run on macOS. The static `_isMacOS`
|
||||
// check inside the service evaluates to true at field-initialization
|
||||
// time, so the ValueNotifiers seed to `unknown` (not `granted`).
|
||||
// This is intentional — on macOS the service must query the native
|
||||
// side before it knows the real state. Tests that inject a channel
|
||||
// observe `unknown` as the initial value and drive transitions from
|
||||
// there. The channel-null short-circuit test documents that when
|
||||
// channel is null, outbound calls are suppressed and each method
|
||||
// returns its safe default.
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/macos_permissions_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
/// Whether the test host is actually macOS — the field initializers
|
||||
/// inside `MacOSPermissionsService` use `Platform.isMacOS` (not
|
||||
/// injectable), so the initial state values depend on this.
|
||||
final bool hostIsMacOS = !kIsWeb && Platform.isMacOS;
|
||||
|
||||
late MethodChannel channel;
|
||||
late List<MethodCall> outgoingCalls;
|
||||
|
||||
/// Intercept outbound calls AFTER the service's handler is installed.
|
||||
/// We store a reference so outbound invokeMethod calls can be
|
||||
/// captured while inbound handlePlatformMessage calls are routed
|
||||
/// to the service's handler.
|
||||
Future<Object?> Function(MethodCall call)? outgoingResponder;
|
||||
|
||||
Future<void> sendInputMonitoringStateChanged({
|
||||
required String state,
|
||||
}) async {
|
||||
const codec = StandardMethodCodec();
|
||||
final encoded = codec.encodeMethodCall(
|
||||
MethodCall(methodInputMonitoringStateChanged, <String, dynamic>{
|
||||
'state': state,
|
||||
}),
|
||||
);
|
||||
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(channel.name, encoded, (_) {});
|
||||
}
|
||||
|
||||
Future<void> sendLocalNetworkStateChanged({
|
||||
required String state,
|
||||
}) async {
|
||||
const codec = StandardMethodCodec();
|
||||
final encoded = codec.encodeMethodCall(
|
||||
MethodCall(methodLocalNetworkStateChanged, <String, dynamic>{
|
||||
'state': state,
|
||||
}),
|
||||
);
|
||||
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(channel.name, encoded, (_) {});
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
channel = const MethodChannel(macOSPermissionsChannelName);
|
||||
outgoingCalls = <MethodCall>[];
|
||||
outgoingResponder = null;
|
||||
|
||||
// The mock handler captures outbound calls AND delegates inbound
|
||||
// platform messages to the service handler when set.
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
outgoingCalls.add(call);
|
||||
final r = outgoingResponder;
|
||||
if (r != null) return r(call);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Initial state
|
||||
// ===========================================================================
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-297: a fresh service exposes a deterministic initial '
|
||||
'inputMonitoringState that matches the host platform',
|
||||
() {
|
||||
final svc = MacOSPermissionsService(channel: channel);
|
||||
if (hostIsMacOS) {
|
||||
// On macOS the service hasn't queried the native side yet.
|
||||
expect(svc.inputMonitoringState.value, MacOSPermissionState.unknown);
|
||||
expect(svc.pttCapabilityState.value, 'L0Focused');
|
||||
} else {
|
||||
// On non-macOS the static check short-circuits to granted.
|
||||
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
|
||||
expect(svc.pttCapabilityState.value, 'L1MacOSEventTap');
|
||||
}
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// Input Monitoring — inbound state changes
|
||||
// ===========================================================================
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
|
||||
'state=Granted transitions inputMonitoringState and PTT capability',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
// Drive away from the initial value first.
|
||||
await sendInputMonitoringStateChanged(state: 'Denied');
|
||||
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
|
||||
expect(svc.pttCapabilityState.value, 'L0Focused');
|
||||
|
||||
var notified = 0;
|
||||
void listener() => notified++;
|
||||
svc.inputMonitoringState.addListener(listener);
|
||||
|
||||
await sendInputMonitoringStateChanged(state: 'Granted');
|
||||
|
||||
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
|
||||
expect(svc.pttCapabilityState.value, 'L1MacOSEventTap');
|
||||
expect(notified, greaterThanOrEqualTo(1));
|
||||
|
||||
svc.inputMonitoringState.removeListener(listener);
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
|
||||
'state=Denied transitions to denied and L0Focused',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
await sendInputMonitoringStateChanged(state: 'Denied');
|
||||
|
||||
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
|
||||
expect(svc.pttCapabilityState.value, 'L0Focused');
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
|
||||
'state=NotDetermined transitions to notDetermined and L0Focused',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
await sendInputMonitoringStateChanged(state: 'NotDetermined');
|
||||
|
||||
expect(
|
||||
svc.inputMonitoringState.value,
|
||||
MacOSPermissionState.notDetermined,
|
||||
);
|
||||
expect(svc.pttCapabilityState.value, 'L0Focused');
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
|
||||
'malformed state parses to unknown and L0Focused',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
// Start from a known baseline.
|
||||
await sendInputMonitoringStateChanged(state: 'Granted');
|
||||
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
|
||||
|
||||
await sendInputMonitoringStateChanged(state: 'Bogus');
|
||||
|
||||
expect(svc.inputMonitoringState.value, MacOSPermissionState.unknown);
|
||||
expect(svc.pttCapabilityState.value, 'L0Focused');
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// Local Network — inbound state changes
|
||||
// ===========================================================================
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-300: inbound localNetworkStateChanged with '
|
||||
'state=Granted transitions localNetworkState',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
await sendLocalNetworkStateChanged(state: 'Granted');
|
||||
|
||||
expect(svc.localNetworkState.value, MacOSLocalNetworkState.granted);
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-300: inbound localNetworkStateChanged with '
|
||||
'state=Denied transitions localNetworkState',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
await sendLocalNetworkStateChanged(state: 'Denied');
|
||||
|
||||
expect(svc.localNetworkState.value, MacOSLocalNetworkState.denied);
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-300: inbound localNetworkStateChanged with '
|
||||
'state=Unsupported transitions localNetworkState',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
await sendLocalNetworkStateChanged(state: 'Unsupported');
|
||||
|
||||
expect(
|
||||
svc.localNetworkState.value,
|
||||
MacOSLocalNetworkState.unsupported,
|
||||
);
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// Outbound method calls
|
||||
// ===========================================================================
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-297: requestInputMonitoring() emits outbound '
|
||||
'requestInputMonitoring MethodCall; returns the platform response',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
outgoingResponder = (call) async {
|
||||
if (call.method == methodRequestInputMonitoring) {
|
||||
return 'Granted';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
final result = await svc.requestInputMonitoring();
|
||||
|
||||
expect(
|
||||
outgoingCalls.where((c) => c.method == methodRequestInputMonitoring),
|
||||
hasLength(1),
|
||||
reason: 'requestInputMonitoring must be called',
|
||||
);
|
||||
expect(result, MacOSPermissionState.granted);
|
||||
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
|
||||
expect(svc.pttCapabilityState.value, 'L1MacOSEventTap');
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-300: triggerLocalNetworkPrompt() emits outbound '
|
||||
'triggerLocalNetworkPrompt MethodCall; returns the platform response',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
outgoingResponder = (call) async {
|
||||
if (call.method == methodTriggerLocalNetworkPrompt) {
|
||||
return 'Granted';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
final result = await svc.triggerLocalNetworkPrompt();
|
||||
|
||||
expect(
|
||||
outgoingCalls
|
||||
.where((c) => c.method == methodTriggerLocalNetworkPrompt),
|
||||
hasLength(1),
|
||||
reason: 'triggerLocalNetworkPrompt must be called',
|
||||
);
|
||||
expect(result, MacOSLocalNetworkState.granted);
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
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',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
outgoingResponder = (call) async {
|
||||
if (call.method == methodRequestNotifications) {
|
||||
return 'Granted';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
final result = await svc.requestNotifications();
|
||||
|
||||
expect(
|
||||
outgoingCalls.where((c) => c.method == methodRequestNotifications),
|
||||
hasLength(1),
|
||||
reason: 'requestNotifications must be called',
|
||||
);
|
||||
expect(result, MacOSPermissionState.granted);
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-297: openInputMonitoringSettings() emits outbound '
|
||||
'openInputMonitoringSettings MethodCall',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
await svc.openInputMonitoringSettings();
|
||||
|
||||
final calls = outgoingCalls
|
||||
.where((c) => c.method == methodOpenInputMonitoringSettings)
|
||||
.toList();
|
||||
expect(calls, hasLength(1));
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// Lifecycle
|
||||
// ===========================================================================
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-297: start() is idempotent — calling it twice '
|
||||
'does not double-register the handler',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)
|
||||
..start()
|
||||
..start();
|
||||
|
||||
var notified = 0;
|
||||
void listener() => notified++;
|
||||
svc.inputMonitoringState.addListener(listener);
|
||||
|
||||
await sendInputMonitoringStateChanged(state: 'Denied');
|
||||
|
||||
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
|
||||
expect(notified, 1);
|
||||
|
||||
svc.inputMonitoringState.removeListener(listener);
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-297: stop() removes the handler — subsequent '
|
||||
'inbound messages have no effect on state',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
await sendInputMonitoringStateChanged(state: 'Denied');
|
||||
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
|
||||
|
||||
svc.stop();
|
||||
|
||||
await sendInputMonitoringStateChanged(state: 'Granted');
|
||||
|
||||
expect(
|
||||
svc.inputMonitoringState.value,
|
||||
MacOSPermissionState.denied,
|
||||
reason: 'state must be frozen after stop()',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// Non-macOS short-circuit (channel is null)
|
||||
// ===========================================================================
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-297: null-channel short-circuit — on non-macOS hosts '
|
||||
'the constructor seeds to safe defaults; on macOS hosts, passing '
|
||||
'channel:null still creates a real channel because _isMacOS is true',
|
||||
() async {
|
||||
if (!hostIsMacOS) {
|
||||
// On non-macOS: the constructor's `_isMacOS` branch is false,
|
||||
// so `_channel` stays null. All methods return safe defaults
|
||||
// without touching any channel.
|
||||
final svc = MacOSPermissionsService(channel: null);
|
||||
expect(
|
||||
svc.inputMonitoringState.value,
|
||||
MacOSPermissionState.granted,
|
||||
);
|
||||
expect(
|
||||
svc.localNetworkState.value,
|
||||
MacOSLocalNetworkState.unsupported,
|
||||
);
|
||||
expect(
|
||||
svc.notificationState.value,
|
||||
MacOSPermissionState.granted,
|
||||
);
|
||||
|
||||
final priorOutgoing = outgoingCalls.length;
|
||||
await svc.requestInputMonitoring();
|
||||
await svc.triggerLocalNetworkPrompt();
|
||||
await svc.requestNotifications();
|
||||
await svc.openInputMonitoringSettings();
|
||||
|
||||
expect(
|
||||
outgoingCalls.length,
|
||||
priorOutgoing,
|
||||
reason: 'no outbound calls when platform is non-macOS',
|
||||
);
|
||||
|
||||
svc.start();
|
||||
svc.stop();
|
||||
svc.dispose();
|
||||
} else {
|
||||
// On macOS: even with channel: null, the constructor creates
|
||||
// a MethodChannel because _isMacOS is true. This is the
|
||||
// correct production behaviour — on macOS the service always
|
||||
// has a channel. The short-circuit path is unreachable on
|
||||
// macOS by design.
|
||||
final svc = MacOSPermissionsService(channel: null);
|
||||
// The service has a non-null _channel, so methods will attempt
|
||||
// to invoke the channel (which has no native handler in tests).
|
||||
// Verify the service doesn't throw and returns a value.
|
||||
final result = await svc.requestInputMonitoring();
|
||||
expect(result, isNotNull);
|
||||
svc.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// Channel error handling
|
||||
// ===========================================================================
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-297: requestInputMonitoring() returns cached state '
|
||||
'when the channel throws',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
// Seed a known state via inbound.
|
||||
await sendInputMonitoringStateChanged(state: 'Denied');
|
||||
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
|
||||
|
||||
// Make the platform stub throw.
|
||||
outgoingResponder = (call) async {
|
||||
if (call.method == methodRequestInputMonitoring) {
|
||||
throw PlatformException(code: 'unavailable');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
final result = await svc.requestInputMonitoring();
|
||||
|
||||
expect(
|
||||
result,
|
||||
MacOSPermissionState.denied,
|
||||
reason:
|
||||
'on channel failure requestInputMonitoring must return cached state',
|
||||
);
|
||||
svc.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SWE4-UV / SRS-300: triggerLocalNetworkPrompt() returns cached state '
|
||||
'when the channel throws',
|
||||
() async {
|
||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||
|
||||
outgoingResponder = (call) async {
|
||||
if (call.method == methodTriggerLocalNetworkPrompt) {
|
||||
throw PlatformException(code: 'unavailable');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
final result = await svc.triggerLocalNetworkPrompt();
|
||||
|
||||
// Returns the cached state without crashing.
|
||||
expect(result, isNotNull);
|
||||
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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -52,11 +52,13 @@ 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,
|
||||
@@ -79,12 +81,14 @@ void main() {
|
||||
localOutputMuted: false,
|
||||
hasJoinPending: false,
|
||||
canJoinVoiceChannel: true,
|
||||
unreadChannelIds: unreadChannelIds,
|
||||
onJoinChannel: (_) {},
|
||||
onJoinChannelWithPassword: (_) {},
|
||||
enableClientLongPressMenu: enableClientLongPressMenu,
|
||||
onOpenClientInfo: onOpenClientInfo,
|
||||
onOpenClientChat: onOpenClientChat,
|
||||
onOpenClientPoke: onOpenClientPoke,
|
||||
onOpenChannelChat: onOpenChannelChat,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -133,6 +137,32 @@ 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 {
|
||||
@@ -493,6 +523,7 @@ void main() {
|
||||
framesSent: 1,
|
||||
framesReceived: 0,
|
||||
pttActive: true,
|
||||
inputLevel: -30.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -549,6 +580,7 @@ void main() {
|
||||
localOutputMuted: false,
|
||||
hasJoinPending: false,
|
||||
canJoinVoiceChannel: true,
|
||||
unreadChannelIds: const {},
|
||||
onJoinChannel: (channel) => tapped = channel,
|
||||
onJoinChannelWithPassword: (_) {},
|
||||
),
|
||||
@@ -576,6 +608,64 @@ 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 {
|
||||
@@ -610,6 +700,7 @@ void main() {
|
||||
localOutputMuted: false,
|
||||
hasJoinPending: false,
|
||||
canJoinVoiceChannel: true,
|
||||
unreadChannelIds: const {},
|
||||
onJoinChannel: (_) {},
|
||||
onJoinChannelWithPassword: (_) {},
|
||||
),
|
||||
@@ -664,6 +755,7 @@ void main() {
|
||||
localOutputMuted: false,
|
||||
hasJoinPending: false,
|
||||
canJoinVoiceChannel: true,
|
||||
unreadChannelIds: const {},
|
||||
onJoinChannel: (_) {},
|
||||
onJoinChannelWithPassword: (_) {},
|
||||
),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/widgets/voice_compact.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('touch PTT releases when disposed while held', (tester) async {
|
||||
final heldChanges = <bool>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: VoicePttButton(
|
||||
active: false,
|
||||
onHeldChanged: heldChanges.add,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final center = tester.getCenter(find.byType(VoicePttButton));
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump();
|
||||
|
||||
expect(heldChanges, [true]);
|
||||
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
|
||||
|
||||
expect(heldChanges, [true, false]);
|
||||
|
||||
await gesture.cancel();
|
||||
|
||||
expect(heldChanges, [true, false]);
|
||||
});
|
||||
}
|
||||
@@ -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"] }
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Used by integration tests to inspect the bookmark DB row layout
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
use chanora_audio::{AudioRoute, PttBackendDescriptor};
|
||||
use chanora_protocol::MessageTarget;
|
||||
|
||||
/// 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,
|
||||
},
|
||||
/// 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,
|
||||
}
|
||||
+149
-305
@@ -52,6 +52,8 @@ use chanora_state::channel_join::{
|
||||
ConnectionEpoch, JoinFailureKind,
|
||||
};
|
||||
|
||||
mod events;
|
||||
mod network_diagnostics;
|
||||
pub mod ptt;
|
||||
|
||||
pub use chanora_audio::{
|
||||
@@ -69,46 +71,11 @@ pub use chanora_protocol::{
|
||||
MessageTarget, ProtocolError, ServerActivity, ServerSnapshot,
|
||||
};
|
||||
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
|
||||
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use events::{
|
||||
NetworkState, PersistedPttBinding, PttDescriptorSnapshot, SessionEvent, VoiceJoinErrorCode,
|
||||
VoiceJoinSyncState,
|
||||
};
|
||||
use network_diagnostics::NetworkDiagnostics;
|
||||
|
||||
/// Errors that can arise during top-level orchestration.
|
||||
#[derive(Debug, Error)]
|
||||
@@ -147,253 +114,11 @@ 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 {
|
||||
route: AudioRoute,
|
||||
},
|
||||
ClientMoved {
|
||||
client_id: u64,
|
||||
new_channel_id: u64,
|
||||
},
|
||||
ClientJoined {
|
||||
client_id: u64,
|
||||
channel_id: u64,
|
||||
name: String,
|
||||
input_muted: bool,
|
||||
output_muted: bool,
|
||||
is_server_query: bool,
|
||||
talk_power: i32,
|
||||
talk_power_granted: bool,
|
||||
},
|
||||
ClientLeft {
|
||||
client_id: u64,
|
||||
name: String,
|
||||
},
|
||||
ClientUpdated {
|
||||
client_id: u64,
|
||||
input_muted: bool,
|
||||
output_muted: bool,
|
||||
is_server_query: bool,
|
||||
talk_power: i32,
|
||||
talk_power_granted: bool,
|
||||
},
|
||||
ChannelAdded {
|
||||
id: u64,
|
||||
parent: u64,
|
||||
name: String,
|
||||
order: i64,
|
||||
has_password: bool,
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
ChannelRemoved {
|
||||
id: u64,
|
||||
},
|
||||
ChannelUpdated {
|
||||
id: u64,
|
||||
name: String,
|
||||
has_password: bool,
|
||||
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.
|
||||
@@ -439,6 +164,10 @@ 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())
|
||||
@@ -1307,8 +1036,8 @@ impl ChanoraSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read audio engine statistics: (frames_sent, frames_received, transmit_active).
|
||||
pub async fn audio_stats(&self) -> Result<(u32, u32, bool), CoreError> {
|
||||
/// Read audio engine statistics: (frames_sent, frames_received, transmit_active, input_level_dbfs).
|
||||
pub async fn audio_stats(&self) -> Result<(u32, u32, bool, f32), CoreError> {
|
||||
let guard = self.inner.lock().await;
|
||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
|
||||
@@ -1316,6 +1045,7 @@ impl ChanoraSession {
|
||||
audio.frames_sent(),
|
||||
audio.frames_received(),
|
||||
audio.transmit_active(),
|
||||
audio.input_level(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1839,8 +1569,7 @@ impl ChanoraSession {
|
||||
|
||||
/// Disconnect from the server. No-op if not connected.
|
||||
pub async fn disconnect(&self) -> Result<(), CoreError> {
|
||||
let mut guard = self.inner.lock().await;
|
||||
if let Some(mut state) = guard.take() {
|
||||
if let Some(mut state) = take_disconnect_state(&self.inner).await {
|
||||
// Signal the supervisor to exit (cancels any backoff sleep).
|
||||
if let Some(tx) = state.cancel_tx.take() {
|
||||
let _ = tx.send(());
|
||||
@@ -1856,7 +1585,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() {
|
||||
let _ = handle.await;
|
||||
await_supervisor_shutdown(handle, SUPERVISOR_SHUTDOWN_TIMEOUT).await;
|
||||
}
|
||||
let _ = self.events_tx.send(SessionEvent::Disconnected {
|
||||
reason: "user requested".to_string(),
|
||||
@@ -1895,6 +1624,22 @@ 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>>>,
|
||||
@@ -1950,27 +1695,77 @@ 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);
|
||||
}
|
||||
@@ -2399,6 +2194,7 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
/// the UI would render. Two snapshots with identical channel
|
||||
/// memberships, names, and orderings produce the same signature;
|
||||
/// any in-channel move, rename, or reorder produces a different one.
|
||||
#[cfg(test)]
|
||||
fn snapshot_signature(snap: &ServerSnapshot) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -2593,6 +2389,54 @@ 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};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Network diagnostics snapshot collected across connection lifetimes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct NetworkDiagnostics {
|
||||
/// Total count of connects (including the initial one).
|
||||
connect_count: u64,
|
||||
/// Count of disconnects (graceful + loss).
|
||||
disconnect_count: u64,
|
||||
/// Recent loss reasons (last 8, ring buffer).
|
||||
loss_reasons: VecDeque<String>,
|
||||
}
|
||||
|
||||
impl NetworkDiagnostics {
|
||||
pub(crate) fn record_connect(&mut self) {
|
||||
self.connect_count = self.connect_count.saturating_add(1);
|
||||
}
|
||||
|
||||
pub(crate) fn record_loss(&mut self, reason: &str) {
|
||||
self.disconnect_count = self.disconnect_count.saturating_add(1);
|
||||
if self.loss_reasons.len() >= 8 {
|
||||
self.loss_reasons.pop_front();
|
||||
}
|
||||
self.loss_reasons.push_back(reason.to_string());
|
||||
}
|
||||
|
||||
pub(crate) fn summary(&self) -> String {
|
||||
let mut s = format!(
|
||||
"connects: {}\ndisconnects: {}\n",
|
||||
self.connect_count, self.disconnect_count
|
||||
);
|
||||
if !self.loss_reasons.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"loss_reasons: [{}]\n",
|
||||
self.loss_reasons
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn network_diagnostics_keeps_last_eight_loss_reasons() {
|
||||
let mut diagnostics = NetworkDiagnostics::default();
|
||||
|
||||
for i in 0..10 {
|
||||
diagnostics.record_loss(&format!("loss-{i}"));
|
||||
}
|
||||
|
||||
assert_eq!(diagnostics.disconnect_count, 10);
|
||||
assert_eq!(diagnostics.loss_reasons.len(), 8);
|
||||
assert_eq!(
|
||||
diagnostics.loss_reasons.front().map(String::as_str),
|
||||
Some("loss-2")
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostics.loss_reasons.back().map(String::as_str),
|
||||
Some("loss-9")
|
||||
);
|
||||
assert!(diagnostics.summary().contains(
|
||||
"loss_reasons: [loss-2, loss-3, loss-4, loss-5, loss-6, loss-7, loss-8, loss-9]"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,13 @@ audiopus = "0.3.0-rc.0"
|
||||
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
||||
rustfft = "6.2.0"
|
||||
crossbeam = { version = "0.8", default-features = false, features = ["alloc", "crossbeam-queue"] }
|
||||
|
||||
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
|
||||
# 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.17.3"
|
||||
cpal = "0.18.0"
|
||||
|
||||
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
|
||||
# Direct CoreAudio AudioUnit access on Apple platforms (DEC-011 follow-up).
|
||||
@@ -57,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.21", default-features = false }
|
||||
jni = { version = "0.22.4", 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
|
||||
@@ -72,13 +73,18 @@ ndk-context = "0.1"
|
||||
# - deduplicated macro impls
|
||||
# - PowerSavingOffloaded PerformanceMode variant
|
||||
oboe = { git = "https://github.com/EdisonJwa/oboe-rs", rev = "a14f9b83ecea8c93f5a692f2ee7808445b938c35" }
|
||||
# Safe slice reinterpret for the oboe stereo output callback.
|
||||
# bytemuck::cast_slice_mut replaces the raw-pointer cast from
|
||||
# `&mut [(f32, f32)]` to `&mut [f32]` with a provenance-correct
|
||||
# and UB-free transmute backed by `NoUninit`.
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices
|
||||
# + 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.54", features = [
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_LibraryLoader",
|
||||
@@ -98,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.5"
|
||||
criterion = "0.8"
|
||||
# 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
|
||||
@@ -141,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.8"
|
||||
rand = "0.10"
|
||||
# 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
|
||||
@@ -160,4 +166,4 @@ rand = "0.8"
|
||||
# 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.37", default-features = false }
|
||||
sdl2 = { version = "0.38", default-features = false }
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossbeam::queue::ArrayQueue;
|
||||
|
||||
/// Fixed-capacity PCM handoff from the Android render producer task to
|
||||
/// the Oboe output callback.
|
||||
pub(crate) struct AndroidRenderRing {
|
||||
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||
}
|
||||
|
||||
impl AndroidRenderRing {
|
||||
pub(crate) fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
frames: Arc::new(ArrayQueue::new((capacity / 2).max(1))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn producer(&self) -> AndroidRenderRingProducer {
|
||||
AndroidRenderRingProducer {
|
||||
frames: Arc::clone(&self.frames),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn consumer(&self) -> AndroidRenderRingConsumer {
|
||||
AndroidRenderRingConsumer {
|
||||
frames: Arc::clone(&self.frames),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AndroidRenderRingProducer {
|
||||
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||
}
|
||||
|
||||
impl AndroidRenderRingProducer {
|
||||
pub(crate) fn push_frame_lossy(&self, samples: &[f32]) {
|
||||
for frame in samples.chunks_exact(2) {
|
||||
let stereo_frame = [frame[0], frame[1]];
|
||||
if self.frames.push(stereo_frame).is_err() {
|
||||
let _ = self.frames.pop();
|
||||
let _ = self.frames.push(stereo_frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AndroidRenderRingConsumer {
|
||||
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||
}
|
||||
|
||||
impl AndroidRenderRingConsumer {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn drain_into_zero_filling(&self, out: &mut [f32]) {
|
||||
let mut chunks = out.chunks_exact_mut(2);
|
||||
for frame_out in &mut chunks {
|
||||
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
|
||||
frame_out.copy_from_slice(&frame);
|
||||
}
|
||||
for sample in chunks.into_remainder() {
|
||||
*sample = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn drain_stereo_into_zero_filling(&self, out: &mut [(f32, f32)]) {
|
||||
for frame_out in out {
|
||||
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
|
||||
*frame_out = (frame[0], frame[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn producer_drops_oldest_samples_when_ring_is_full() {
|
||||
let ring = AndroidRenderRing::new(4);
|
||||
let producer = ring.producer();
|
||||
let consumer = ring.consumer();
|
||||
|
||||
producer.push_frame_lossy(&[1.0, 2.0, 3.0, 4.0]);
|
||||
producer.push_frame_lossy(&[5.0, 6.0]);
|
||||
|
||||
let mut out = [0.0; 4];
|
||||
consumer.drain_into_zero_filling(&mut out);
|
||||
|
||||
assert_eq!(out, [3.0, 4.0, 5.0, 6.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_after_partial_consumer_drain_preserves_stereo_pairing() {
|
||||
let ring = AndroidRenderRing::new(4);
|
||||
let producer = ring.producer();
|
||||
let consumer = ring.consumer();
|
||||
|
||||
producer.push_frame_lossy(&[1.0, 10.0, 2.0, 20.0]);
|
||||
|
||||
let mut odd_out = [9.0];
|
||||
consumer.drain_into_zero_filling(&mut odd_out);
|
||||
assert_eq!(odd_out, [0.0]);
|
||||
|
||||
producer.push_frame_lossy(&[3.0, 30.0]);
|
||||
|
||||
let mut out = [0.0; 4];
|
||||
consumer.drain_into_zero_filling(&mut out);
|
||||
|
||||
assert_eq!(out, [2.0, 20.0, 3.0, 30.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumer_zero_fills_tail_on_underrun() {
|
||||
let ring = AndroidRenderRing::new(4);
|
||||
let producer = ring.producer();
|
||||
let consumer = ring.consumer();
|
||||
|
||||
producer.push_frame_lossy(&[0.25, -0.25]);
|
||||
|
||||
let mut out = [9.0; 4];
|
||||
consumer.drain_into_zero_filling(&mut out);
|
||||
|
||||
assert_eq!(out, [0.25, -0.25, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chanora_protocol::InAudioBuf;
|
||||
use crossbeam::queue::ArrayQueue;
|
||||
|
||||
use crate::engine::SessionAudioId;
|
||||
|
||||
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,
|
||||
/// Raw inbound TeamSpeak audio payload accepted by AudioHandler::handle_packet.
|
||||
pub data: InAudioBuf,
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
|
||||
/// Lock-free bridge between the inbound forwarder / main thread and the
|
||||
/// audio callback. The callback owns the consumer halves.
|
||||
pub struct AudioEventQueue {
|
||||
/// Bounded lossy queue for raw voice packets. On overflow, the push
|
||||
/// fails and the packet is dropped (counted via `packets_dropped`).
|
||||
/// Capacity: 100 packets (~2 seconds at 50pps, far more than needed).
|
||||
pub packet_queue: ArrayQueue<AudioPacket>,
|
||||
/// Bounded reliable queue for control commands (volume, client removal).
|
||||
/// On overflow, the caller retries. Capacity: 32 commands.
|
||||
pub control_queue: ArrayQueue<AudioCommand>,
|
||||
/// Atomic counter for dropped packets (for diagnostics).
|
||||
pub packets_dropped: AtomicU64,
|
||||
}
|
||||
|
||||
impl AudioEventQueue {
|
||||
/// Create the Android audio event bridge with fixed queue capacities.
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
packet_queue: ArrayQueue::new(PACKET_QUEUE_CAPACITY),
|
||||
control_queue: ArrayQueue::new(CONTROL_QUEUE_CAPACITY),
|
||||
packets_dropped: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a producer handle sharing this queue.
|
||||
pub fn producer(queue: &Arc<Self>) -> AudioEventProducer {
|
||||
AudioEventProducer {
|
||||
queue: Arc::clone(queue),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a consumer handle sharing this queue.
|
||||
pub fn consumer(queue: &Arc<Self>) -> AudioEventConsumer {
|
||||
AudioEventConsumer {
|
||||
queue: Arc::clone(queue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Producer side used by the inbound forwarder and engine control methods.
|
||||
#[derive(Clone)]
|
||||
pub struct AudioEventProducer {
|
||||
queue: Arc<AudioEventQueue>,
|
||||
}
|
||||
|
||||
impl AudioEventProducer {
|
||||
/// Push a raw voice packet, incrementing the drop counter if full.
|
||||
pub fn push_packet(&self, packet: AudioPacket) -> Result<(), AudioPacket> {
|
||||
self.queue.packet_queue.push(packet).map_err(|packet| {
|
||||
self.queue.packets_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
packet
|
||||
})
|
||||
}
|
||||
|
||||
/// Push a control command, returning it unchanged if the queue is full.
|
||||
pub fn push_control(&self, cmd: AudioCommand) -> Result<(), AudioCommand> {
|
||||
self.queue.control_queue.push(cmd)
|
||||
}
|
||||
|
||||
/// Shared queue backing this producer.
|
||||
pub fn queue(&self) -> Arc<AudioEventQueue> {
|
||||
Arc::clone(&self.queue)
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumer side used by the Android output callback.
|
||||
pub struct AudioEventConsumer {
|
||||
queue: Arc<AudioEventQueue>,
|
||||
}
|
||||
|
||||
impl AudioEventConsumer {
|
||||
/// Pop up to `cap` queued packets.
|
||||
pub fn drain_packets(&self, cap: usize) -> impl Iterator<Item = AudioPacket> + '_ {
|
||||
let mut drained = 0;
|
||||
std::iter::from_fn(move || {
|
||||
if drained >= cap {
|
||||
return None;
|
||||
}
|
||||
let packet = self.queue.packet_queue.pop();
|
||||
if packet.is_some() {
|
||||
drained += 1;
|
||||
}
|
||||
packet
|
||||
})
|
||||
}
|
||||
|
||||
/// Pop all currently queued controls.
|
||||
pub fn drain_controls(&self) -> impl Iterator<Item = AudioCommand> + '_ {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,6 +404,19 @@ impl Default for SharedAudioProcessingStats {
|
||||
}
|
||||
|
||||
impl SharedAudioProcessingStats {
|
||||
/// Store the raw input dBFS level (desktop capture path).
|
||||
/// Mobile platforms use [`Self::update_capture`] instead, which
|
||||
/// also records VAD state; this lighter method is for the cpal
|
||||
/// capture path that has no VAD pipeline.
|
||||
pub fn set_input_dbfs(&self, dbfs: f32) {
|
||||
self.input_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Read the current input dBFS level.
|
||||
pub fn input_dbfs(&self) -> f32 {
|
||||
f32::from_bits(self.input_dbfs.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Store capture levels and VAD state.
|
||||
pub fn update_capture(
|
||||
&self,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
pub(crate) fn append_processed_i16_bounded(
|
||||
pcm_accum: &mut Vec<i16>,
|
||||
frame: &[f32],
|
||||
gain: f32,
|
||||
) -> bool {
|
||||
if (gain - 1.0).abs() < f32::EPSILON {
|
||||
for src in frame.iter().copied() {
|
||||
if pcm_accum.len() == pcm_accum.capacity() {
|
||||
return true;
|
||||
}
|
||||
pcm_accum.push(crate::frame::f32_to_i16(src));
|
||||
}
|
||||
} else {
|
||||
for src in frame.iter().copied() {
|
||||
if pcm_accum.len() == pcm_accum.capacity() {
|
||||
return true;
|
||||
}
|
||||
let scaled = (crate::frame::f32_to_i16(src) as f32) * gain;
|
||||
pcm_accum.push(scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn append_i16_bounded(pcm_accum: &mut Vec<i16>, frame: &[i16]) -> bool {
|
||||
for src in frame.iter().copied() {
|
||||
if pcm_accum.len() == pcm_accum.capacity() {
|
||||
return true;
|
||||
}
|
||||
pcm_accum.push(src);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{append_i16_bounded, append_processed_i16_bounded};
|
||||
|
||||
#[test]
|
||||
fn append_processed_i16_bounded_does_not_grow_when_full() {
|
||||
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
|
||||
|
||||
assert!(dropped);
|
||||
assert_eq!(accum.len(), warmed_capacity);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_processed_i16_bounded_preserves_expected_10ms_append() {
|
||||
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
|
||||
|
||||
assert!(!dropped);
|
||||
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_i16_bounded_does_not_grow_when_preroll_exceeds_capacity() {
|
||||
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
let dropped = append_i16_bounded(&mut accum, &frame);
|
||||
|
||||
assert!(dropped);
|
||||
assert_eq!(accum.len(), warmed_capacity);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_i16_bounded_preserves_expected_10ms_append() {
|
||||
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
let dropped = append_i16_bounded(&mut accum, &frame);
|
||||
|
||||
assert!(!dropped);
|
||||
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_i16_bounded_preserves_full_vad_preroll_window() {
|
||||
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES * 16);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
for _ in 0..16 {
|
||||
assert!(!append_i16_bounded(&mut accum, &frame));
|
||||
}
|
||||
|
||||
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES * 16);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
assert!(append_i16_bounded(&mut accum, &frame));
|
||||
assert_eq!(accum.len(), warmed_capacity);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
pub(crate) struct CaptureResampleResult {
|
||||
pub(crate) output_len: usize,
|
||||
pub(crate) dropped: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn resample_capture_to_48k(
|
||||
samples: &[i16],
|
||||
input_sample_rate_hz: u32,
|
||||
resample_pos: &mut f64,
|
||||
resample_last: &mut i16,
|
||||
scratch: &mut Vec<i16>,
|
||||
) -> CaptureResampleResult {
|
||||
scratch.clear();
|
||||
if samples.is_empty() {
|
||||
return CaptureResampleResult {
|
||||
output_len: 0,
|
||||
dropped: false,
|
||||
};
|
||||
}
|
||||
|
||||
let ratio = input_sample_rate_hz.max(1) as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
|
||||
let mut pos = *resample_pos;
|
||||
let mut dropped = false;
|
||||
while pos < samples.len() as f64 {
|
||||
let i = pos.floor() as isize;
|
||||
let frac = pos - i as f64;
|
||||
let a = if i <= 0 {
|
||||
*resample_last as f64
|
||||
} else {
|
||||
samples[(i - 1) as usize] as f64
|
||||
};
|
||||
let b = if i < samples.len() as isize {
|
||||
samples[i as usize] as f64
|
||||
} else {
|
||||
a
|
||||
};
|
||||
let value = (a + frac * (b - a))
|
||||
.round()
|
||||
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
|
||||
if scratch.len() < scratch.capacity() {
|
||||
scratch.push(value);
|
||||
} else {
|
||||
dropped = true;
|
||||
}
|
||||
pos += ratio;
|
||||
}
|
||||
*resample_pos = pos - samples.len() as f64;
|
||||
*resample_last = *samples.last().unwrap_or(resample_last);
|
||||
CaptureResampleResult {
|
||||
output_len: scratch.len(),
|
||||
dropped,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod android_voice_unit_resampler_tests {
|
||||
use super::resample_capture_to_48k;
|
||||
|
||||
#[test]
|
||||
fn android_voice_unit_resampler_reuses_scratch_without_capacity_growth() {
|
||||
let samples: Vec<i16> = (0..882).map(|i| i as i16).collect();
|
||||
let mut pos = 0.0;
|
||||
let mut last = 0_i16;
|
||||
let mut scratch = Vec::with_capacity(960);
|
||||
|
||||
let first = resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
|
||||
let first_len = first.output_len;
|
||||
assert_eq!(first_len, 960);
|
||||
assert!(!first.dropped);
|
||||
assert_eq!(scratch.len(), first_len);
|
||||
let warmed_capacity = scratch.capacity();
|
||||
let warmed_ptr = scratch.as_ptr();
|
||||
|
||||
for _ in 0..8 {
|
||||
let result =
|
||||
resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
|
||||
let len = result.output_len;
|
||||
assert_eq!(len, scratch.len());
|
||||
assert!(len >= 959 && len <= 960);
|
||||
assert!(!result.dropped);
|
||||
assert_eq!(scratch.capacity(), warmed_capacity);
|
||||
assert_eq!(scratch.as_ptr(), warmed_ptr);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn android_voice_unit_resampler_truncates_oversized_burst_without_capacity_growth() {
|
||||
let samples: Vec<i16> = (0..4_800).map(|i| i as i16).collect();
|
||||
let mut pos = 0.0;
|
||||
let mut last = 0_i16;
|
||||
let mut scratch = Vec::with_capacity(960);
|
||||
let warmed_capacity = scratch.capacity();
|
||||
let warmed_ptr = scratch.as_ptr();
|
||||
|
||||
let result = resample_capture_to_48k(&samples, 48_000, &mut pos, &mut last, &mut scratch);
|
||||
|
||||
assert_eq!(result.output_len, warmed_capacity);
|
||||
assert!(result.dropped);
|
||||
assert_eq!(scratch.len(), warmed_capacity);
|
||||
assert_eq!(scratch.capacity(), warmed_capacity);
|
||||
assert_eq!(scratch.as_ptr(), warmed_ptr);
|
||||
assert_eq!(pos, 0.0);
|
||||
assert_eq!(last, *samples.last().unwrap());
|
||||
}
|
||||
}
|
||||
@@ -314,6 +314,15 @@ 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.
|
||||
|
||||
+281
-115
@@ -52,6 +52,8 @@ use tsclientlib::audio::AudioHandler;
|
||||
|
||||
use chanora_protocol::{InboundVoice, OutPacket};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
use crate::audio_event_queue::{AudioCommand, AudioEventQueue, AudioPacket};
|
||||
use crate::AudioError;
|
||||
|
||||
#[cfg(all(
|
||||
@@ -305,12 +307,15 @@ pub struct AudioEngine {
|
||||
output_muted: Arc<AtomicBool>,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
#[cfg(not(target_os = "android"))]
|
||||
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
||||
#[cfg(target_os = "android")]
|
||||
audio_event_producer: crate::audio_event_queue::AudioEventProducer,
|
||||
#[cfg(target_os = "android")]
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
||||
#[cfg(target_os = "android")]
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
||||
#[cfg(target_os = "android")]
|
||||
mic_gain: f32,
|
||||
// Streams must be dropped to stop audio. Both are `!Send` because
|
||||
// cpal's Stream isn't Send on some backends; we keep them in an
|
||||
@@ -482,7 +487,7 @@ impl AudioEngine {
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_gate: crate::ptt::AudioTransmitGate,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
event_producer: crate::audio_event_queue::AudioEventProducer,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
@@ -552,7 +557,8 @@ impl AudioEngine {
|
||||
transmit_active: transmit_gate.flag_arc(),
|
||||
frames_sent: frames_sent.clone(),
|
||||
mic_gain,
|
||||
handler: audio_handler.clone(),
|
||||
handler: AudioHandler::new(),
|
||||
event_producer: event_producer.clone(),
|
||||
output_gain: output_gain.clone(),
|
||||
output_muted: output_muted.clone(),
|
||||
voice_activity_selector: voice_activity_selector.clone(),
|
||||
@@ -572,7 +578,7 @@ impl AudioEngine {
|
||||
voice_out_tx.clone(),
|
||||
transmit_gate.clone(),
|
||||
frames_sent.clone(),
|
||||
audio_handler.clone(),
|
||||
event_producer.clone(),
|
||||
output_gain.clone(),
|
||||
output_muted.clone(),
|
||||
voice_activity_selector.clone(),
|
||||
@@ -832,6 +838,7 @@ impl AudioEngine {
|
||||
transmit_flag_for_capture,
|
||||
frames_sent.clone(),
|
||||
cfg.mic_gain,
|
||||
audio_processing_stats.clone(),
|
||||
);
|
||||
let (input_stream, capture_active) = match capture_result {
|
||||
Ok(s) => (Some(s), true),
|
||||
@@ -974,7 +981,6 @@ impl AudioEngine {
|
||||
|
||||
Ok(Self {
|
||||
transmit_gate,
|
||||
frames_sent,
|
||||
frames_received,
|
||||
output_gain,
|
||||
output_muted,
|
||||
@@ -1007,8 +1013,8 @@ impl AudioEngine {
|
||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
|
||||
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||
Arc::new(Mutex::new(AudioHandler::new()));
|
||||
let event_queue = AudioEventQueue::new();
|
||||
let event_producer = AudioEventQueue::producer(&event_queue);
|
||||
|
||||
if !cfg.mobile_voice_preset {
|
||||
return Err(AudioError::Backend(
|
||||
@@ -1069,7 +1075,8 @@ impl AudioEngine {
|
||||
transmit_active: transmit_flag_for_capture,
|
||||
frames_sent: frames_sent.clone(),
|
||||
mic_gain: cfg.mic_gain,
|
||||
handler: audio_handler.clone(),
|
||||
handler: AudioHandler::new(),
|
||||
event_producer: event_producer.clone(),
|
||||
output_gain: output_gain.clone(),
|
||||
output_muted: output_muted.clone(),
|
||||
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
||||
@@ -1077,11 +1084,25 @@ impl AudioEngine {
|
||||
audio_processing_stats: audio_processing_stats.clone(),
|
||||
};
|
||||
let mut android_voice_unit =
|
||||
crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params).map_err(|e| {
|
||||
AudioError::Backend(format!("android: failed to open Oboe voice unit: {e}"))
|
||||
})?;
|
||||
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}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
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}"
|
||||
)));
|
||||
@@ -1103,7 +1124,7 @@ impl AudioEngine {
|
||||
voice_out_tx.clone(),
|
||||
transmit_gate.clone(),
|
||||
frames_sent.clone(),
|
||||
audio_handler.clone(),
|
||||
event_producer.clone(),
|
||||
output_gain.clone(),
|
||||
output_muted.clone(),
|
||||
cfg.voice_activity_selector.clone(),
|
||||
@@ -1151,7 +1172,7 @@ impl AudioEngine {
|
||||
let capture_active = true;
|
||||
|
||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
let handler_for_task = audio_handler.clone();
|
||||
let event_producer_for_task = event_producer.clone();
|
||||
let frames_received_for_task = frames_received.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
@@ -1164,10 +1185,8 @@ impl AudioEngine {
|
||||
match item {
|
||||
Some(v) => {
|
||||
let id = SessionAudioId(v.from_client);
|
||||
let mut h = handler_for_task.lock().unwrap();
|
||||
if let Err(e) = h.handle_packet(id, v.packet) {
|
||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||
} else {
|
||||
let packet = AudioPacket { client_id: id, data: v.packet };
|
||||
if event_producer_for_task.push_packet(packet).is_ok() {
|
||||
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -1186,7 +1205,7 @@ impl AudioEngine {
|
||||
output_muted,
|
||||
audio_processing_config,
|
||||
audio_processing_stats,
|
||||
audio_handler,
|
||||
audio_event_producer: event_producer,
|
||||
voice_out_tx,
|
||||
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
||||
mic_gain: cfg.mic_gain,
|
||||
@@ -1240,6 +1259,7 @@ 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();
|
||||
@@ -1272,8 +1292,8 @@ impl AudioEngine {
|
||||
// VPIO render callback (commit 4) finds decoded frames
|
||||
// waiting.
|
||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
let handler_for_task = audio_handler.clone();
|
||||
let frames_received_for_task = frames_received.clone();
|
||||
let handler_for_task = audio_handler.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -1286,10 +1306,15 @@ impl AudioEngine {
|
||||
Some(v) => {
|
||||
let id = SessionAudioId(v.from_client);
|
||||
let mut h = handler_for_task.lock().unwrap();
|
||||
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);
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
@@ -1308,15 +1333,49 @@ impl AudioEngine {
|
||||
audio_processing_config,
|
||||
audio_processing_stats,
|
||||
audio_handler,
|
||||
voice_out_tx,
|
||||
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
||||
mic_gain: cfg.mic_gain,
|
||||
_ios_voice_backend: Mutex::new(Some(ios_voice_backend)),
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
capture_active,
|
||||
})
|
||||
}
|
||||
|
||||
#[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() {
|
||||
@@ -1470,7 +1529,8 @@ impl AudioEngine {
|
||||
transmit_active: self.transmit_gate.flag_arc(),
|
||||
frames_sent: self.frames_sent.clone(),
|
||||
mic_gain: self.mic_gain,
|
||||
handler: self.audio_handler.clone(),
|
||||
handler: AudioHandler::new(),
|
||||
event_producer: self.audio_event_producer.clone(),
|
||||
output_gain: self.output_gain.clone(),
|
||||
output_muted: self.output_muted.clone(),
|
||||
voice_activity_selector: self.voice_activity_selector.clone(),
|
||||
@@ -1491,7 +1551,7 @@ impl AudioEngine {
|
||||
self.voice_out_tx.clone(),
|
||||
self.transmit_gate.clone(),
|
||||
self.frames_sent.clone(),
|
||||
self.audio_handler.clone(),
|
||||
self.audio_event_producer.clone(),
|
||||
self.output_gain.clone(),
|
||||
self.output_muted.clone(),
|
||||
self.voice_activity_selector.clone(),
|
||||
@@ -1594,6 +1654,11 @@ impl AudioEngine {
|
||||
self.frames_received.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
|
||||
pub fn input_level(&self) -> f32 {
|
||||
self.audio_processing_stats.input_dbfs()
|
||||
}
|
||||
|
||||
/// Current audio-processing config snapshot.
|
||||
pub fn audio_processing_config_snapshot(&self) -> crate::AudioProcessingConfig {
|
||||
self.audio_processing_config.lock().unwrap().clone()
|
||||
@@ -1658,20 +1723,42 @@ impl AudioEngine {
|
||||
/// `0.0..4.0`.
|
||||
pub fn set_client_volume(&self, client_id: u64, volume: f32) {
|
||||
let clamped = volume.clamp(0.0, 4.0);
|
||||
match self.audio_handler.lock() {
|
||||
Ok(mut h) => {
|
||||
if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) {
|
||||
q.volume = clamped;
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let mut cmd = AudioCommand::SetVolume(SessionAudioId(client_id), clamped);
|
||||
for _ in 0..64 {
|
||||
match self.audio_event_producer.push_control(cmd) {
|
||||
Ok(()) => return,
|
||||
Err(returned) => {
|
||||
cmd = returned;
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "chanora_audio",
|
||||
client_id,
|
||||
volume = clamped,
|
||||
error = %e,
|
||||
"set_client_volume: audio_handler lock poisoned — volume not applied"
|
||||
);
|
||||
tracing::warn!(
|
||||
target: "chanora_audio",
|
||||
client_id,
|
||||
volume = clamped,
|
||||
"set_client_volume: control queue full after 64 retries — volume not applied"
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
match self.audio_handler.lock() {
|
||||
Ok(mut h) => {
|
||||
if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) {
|
||||
q.volume = clamped;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "chanora_audio",
|
||||
client_id,
|
||||
volume = clamped,
|
||||
error = %e,
|
||||
"set_client_volume: audio_handler lock poisoned — volume not applied"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1683,6 +1770,33 @@ 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")))]
|
||||
@@ -1692,6 +1806,7 @@ fn try_open_capture(
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Result<cpal::Stream, AudioError> {
|
||||
let in_cfg = in_dev
|
||||
.default_input_config()
|
||||
@@ -1724,9 +1839,13 @@ fn try_open_capture(
|
||||
in_sample_rate,
|
||||
in_channels,
|
||||
mic_gain,
|
||||
voice_out_tx,
|
||||
crate::opus_voice::start_out_packet_worker(
|
||||
voice_out_tx,
|
||||
frames_sent.clone(),
|
||||
"cpal-capture",
|
||||
)?,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
audio_processing_stats,
|
||||
)));
|
||||
|
||||
let stream = match in_format {
|
||||
@@ -1760,11 +1879,10 @@ struct CaptureState {
|
||||
/// Linux ALSA defaults).
|
||||
resample_last: f32,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
/// 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
|
||||
@@ -1778,8 +1896,27 @@ struct CaptureState {
|
||||
/// capacity so the drain-into-frame path skips the allocator
|
||||
/// 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(
|
||||
@@ -1787,9 +1924,9 @@ impl CaptureState {
|
||||
in_sample_rate: u32,
|
||||
in_channels: usize,
|
||||
mic_gain: f32,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Self {
|
||||
Self {
|
||||
encoder,
|
||||
@@ -1802,13 +1939,13 @@ impl CaptureState {
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
// Generous upper bound for typical cpal periods
|
||||
// (commonly 256..1024 frames); `clear()` retains the
|
||||
// backing allocation across callbacks. See struct doc.
|
||||
mono_scratch: Vec::with_capacity(4096),
|
||||
// Exact upper bound: drain pulls FRAME_SAMPLES at a time.
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1816,17 +1953,8 @@ impl CaptureState {
|
||||
/// 48 kHz mono frames; encode and send when `transmit_active`
|
||||
/// is true (PTT engaged).
|
||||
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
// Drain accumulator while muted so we don't pop on PTT release.
|
||||
self.pcm_accum.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Down-mix to mono + gain.
|
||||
// Reuse `self.mono_scratch` to avoid a per-callback Vec
|
||||
// allocation on the realtime audio thread; see struct
|
||||
// doc and the engine.rs:1389-1397 precedent for why this
|
||||
// matters for user-perceptible audio popping.
|
||||
// 1. Down-mix to mono (pre-gain). Always performed so the level
|
||||
// meter reflects real mic input even when PTT is released.
|
||||
let in_channels = self.in_channels;
|
||||
let mic_gain = self.mic_gain;
|
||||
self.mono_scratch.clear();
|
||||
@@ -1834,8 +1962,28 @@ impl CaptureState {
|
||||
self.mono_scratch.reserve(frame_count);
|
||||
for frame in buf.chunks(in_channels) {
|
||||
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
|
||||
self.mono_scratch
|
||||
.push((sum / frame.len() as f32) * mic_gain);
|
||||
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));
|
||||
}
|
||||
|
||||
if mic_gain != 1.0 {
|
||||
for s in &mut self.mono_scratch {
|
||||
*s *= mic_gain;
|
||||
}
|
||||
}
|
||||
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
self.pcm_accum.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Resample to 48 kHz if needed. We re-borrow
|
||||
@@ -1887,7 +2035,6 @@ impl CaptureState {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
@@ -2301,6 +2448,13 @@ 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
|
||||
@@ -2308,7 +2462,10 @@ impl std::error::Error for AudioModeError {}
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_audio_manager_call<F, R>(op: F) -> Result<R, AudioModeError>
|
||||
where
|
||||
F: FnOnce(&mut jni::JNIEnv, &jni::objects::JObject) -> Result<R, AudioModeError>
|
||||
F: for<'local> FnOnce(
|
||||
&mut jni::Env<'local>,
|
||||
&jni::objects::JObject<'local>,
|
||||
) -> Result<R, AudioModeError>
|
||||
+ std::panic::UnwindSafe,
|
||||
{
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
@@ -2325,42 +2482,40 @@ 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 _) }
|
||||
.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 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 context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||
|
||||
let service_name: JString =
|
||||
env.new_string("audio")
|
||||
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)],
|
||||
)
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "new_string",
|
||||
method: "getSystemService",
|
||||
detail: e.to_string(),
|
||||
})?
|
||||
.l()
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getSystemService",
|
||||
detail: format!("obj cast: {e}"),
|
||||
})?;
|
||||
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)
|
||||
if audio_manager.is_null() {
|
||||
return Err(AudioModeError::Other(
|
||||
"AudioManager service is null".to_string(),
|
||||
));
|
||||
}
|
||||
op(env, &audio_manager)
|
||||
})
|
||||
});
|
||||
match result {
|
||||
Ok(inner) => inner,
|
||||
@@ -2379,16 +2534,21 @@ 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, "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}"),
|
||||
})
|
||||
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}"),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2402,11 +2562,16 @@ 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, "setMode", "(I)V", &[JValue::Int(mode)])
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "setMode",
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
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(),
|
||||
})?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -2463,6 +2628,7 @@ pub mod bench_seam {
|
||||
tx,
|
||||
transmit_active.clone(),
|
||||
frames_sent,
|
||||
Arc::new(crate::SharedAudioProcessingStats::default()),
|
||||
);
|
||||
Self {
|
||||
state,
|
||||
|
||||
@@ -42,13 +42,11 @@ 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;
|
||||
|
||||
@@ -63,43 +61,10 @@ 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.
|
||||
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 {}
|
||||
type RenderReferenceBuffer = crate::render_reference::RenderReferenceBuffer<480, 4>;
|
||||
type RenderReferenceFrameAccumulator =
|
||||
crate::render_reference::RenderReferenceFrameAccumulator<480>;
|
||||
const RAW_RENDER_SCRATCH_FRAMES: usize = 1024;
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Capture pipeline state //
|
||||
@@ -109,10 +74,9 @@ mod inner {
|
||||
encoder: OpusEncoder,
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
@@ -128,7 +92,6 @@ 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 {
|
||||
@@ -146,10 +109,13 @@ 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: params.voice_out_tx.clone(),
|
||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||
params.voice_out_tx.clone(),
|
||||
params.frames_sent.clone(),
|
||||
"ios-raw",
|
||||
)?,
|
||||
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(),
|
||||
@@ -166,7 +132,6 @@ mod inner {
|
||||
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: 0,
|
||||
fallback_warned_backend: None,
|
||||
wav_recorder: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,6 +169,7 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -213,7 +179,10 @@ mod inner {
|
||||
return;
|
||||
}
|
||||
|
||||
// Encode complete 20 ms Opus frames.
|
||||
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]);
|
||||
@@ -223,7 +192,6 @@ mod inner {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
@@ -253,20 +221,18 @@ mod inner {
|
||||
}
|
||||
let input_dbfs = crate::frame::dbfs(&frame);
|
||||
|
||||
// WAV tap: raw mic (before processing).
|
||||
if let Some(ref rec) = self.wav_recorder {
|
||||
rec.push_raw_mic(&frame);
|
||||
}
|
||||
// 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.
|
||||
|
||||
// Feed render reference to WebRTC APM before capture so AEC can adapt.
|
||||
let render_ref = self.render_reference.read_latest();
|
||||
self.webrtc_apm_processor.process_render(&render_ref);
|
||||
self.webrtc_apm_processor.process_capture(&mut frame);
|
||||
|
||||
// WAV tap: processed mic (after WebRTC APM).
|
||||
if let Some(ref rec) = self.wav_recorder {
|
||||
rec.push_processed_mic(&frame);
|
||||
}
|
||||
// Processed-mic debug WAV capture is disabled for the same
|
||||
// realtime allocation reason as the raw-mic tap above.
|
||||
|
||||
let voice_activity_mode = self
|
||||
.voice_activity_selector
|
||||
@@ -300,14 +266,9 @@ mod inner {
|
||||
self.current_vad_backend = vad_backend;
|
||||
self.fallback_warned_backend = None;
|
||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
self.silero_coreml_worker =
|
||||
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
|
||||
if self.silero_coreml_worker.is_none() {
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
} else {
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
self.silero_coreml_worker = None;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
} else {
|
||||
self.silero_coreml_worker = None;
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
@@ -325,20 +286,38 @@ mod inner {
|
||||
speech: true,
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
if let Some(worker) = self.silero_coreml_worker.as_ref() {
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
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,
|
||||
)
|
||||
}
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
@@ -346,13 +325,10 @@ mod inner {
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
@@ -378,15 +354,12 @@ mod inner {
|
||||
return;
|
||||
}
|
||||
|
||||
let gain = self.mic_gain;
|
||||
if (gain - 1.0).abs() < f32::EPSILON {
|
||||
self.pcm_accum
|
||||
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
||||
} else {
|
||||
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
||||
let scaled = crate::frame::f32_to_i16(s) as f32 * gain;
|
||||
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
||||
}));
|
||||
if crate::capture_accumulator::append_processed_i16_bounded(
|
||||
&mut self.pcm_accum,
|
||||
&frame,
|
||||
self.mic_gain,
|
||||
) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,7 +419,9 @@ mod inner {
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
|
||||
|
||||
let mut scratch: Vec<f32> = Vec::with_capacity(2048);
|
||||
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 handler = params.handler.clone();
|
||||
let output_gain = params.output_gain.clone();
|
||||
let output_muted = params.output_muted.clone();
|
||||
@@ -455,9 +430,10 @@ mod inner {
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let out = args.data.buffer;
|
||||
let n = out.len();
|
||||
let stereo_n = n * 2;
|
||||
if scratch.len() < stereo_n {
|
||||
scratch.resize(stereo_n, 0.0);
|
||||
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();
|
||||
}
|
||||
scratch[..stereo_n].fill(0.0);
|
||||
|
||||
@@ -475,22 +451,26 @@ 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 ref_frame[..mono_n],
|
||||
&mut mono[..process_n],
|
||||
);
|
||||
render_ref_buf.write(&ref_frame);
|
||||
render_ref_accum.push_mono_samples(&mono[..process_n], |frame| {
|
||||
render_ref_buf.write(frame);
|
||||
});
|
||||
|
||||
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
||||
let muted = output_muted.load(Ordering::Relaxed);
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
||||
&scratch[..stereo_n],
|
||||
out,
|
||||
&mut out[..process_n],
|
||||
1,
|
||||
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, AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use audiopus::coder::Encoder as OpusEncoder;
|
||||
@@ -74,12 +74,11 @@ use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
||||
use coreaudio::audio_unit::render_callback::{self, data};
|
||||
use coreaudio::audio_unit::IOType;
|
||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
||||
use tokio::sync::mpsc;
|
||||
use crossbeam::queue::ArrayQueue;
|
||||
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
|
||||
@@ -104,6 +103,15 @@ 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
|
||||
@@ -131,10 +139,9 @@ struct IosCaptureState {
|
||||
/// jitter without reallocating.
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
@@ -153,7 +160,6 @@ 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 {
|
||||
@@ -161,20 +167,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,
|
||||
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
||||
) -> Result<Self, AudioError> {
|
||||
fn new(params: &VoiceAudioParams) -> Result<Self, AudioError> {
|
||||
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
|
||||
|
||||
Ok(Self {
|
||||
encoder,
|
||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||
pcm_accum: Vec::with_capacity(CAPTURE_ACCUM_CAPACITY_SAMPLES),
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: params.voice_out_tx.clone(),
|
||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||
params.voice_out_tx.clone(),
|
||||
params.frames_sent.clone(),
|
||||
"ios-vpio",
|
||||
)?,
|
||||
transmit_active: params.transmit_active.clone(),
|
||||
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(),
|
||||
@@ -192,7 +198,6 @@ impl IosCaptureState {
|
||||
pre_roll_count: 0,
|
||||
pre_roll_flushed: false,
|
||||
capture_frame_seq: 0,
|
||||
wav_recorder,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,7 +271,6 @@ impl IosCaptureState {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
@@ -297,25 +301,9 @@ 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,
|
||||
route,
|
||||
processing_backend,
|
||||
) = self
|
||||
let (run_ns, run_agc, run_hpf, vad_backend, vad_hangover, debug_wav_dump_enabled) = self
|
||||
.audio_processing_config
|
||||
.try_lock()
|
||||
.map(|cfg| {
|
||||
@@ -331,8 +319,6 @@ impl IosCaptureState {
|
||||
cfg.vad_backend,
|
||||
cfg.vad_hangover_ms,
|
||||
cfg.debug_wav_dump_enabled,
|
||||
cfg.route,
|
||||
cfg.processing_backend,
|
||||
)
|
||||
})
|
||||
.unwrap_or((
|
||||
@@ -342,10 +328,13 @@ 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()
|
||||
@@ -358,32 +347,13 @@ 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 =
|
||||
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);
|
||||
}
|
||||
self.silero_coreml_worker = None;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
} else {
|
||||
self.silero_coreml_worker = None;
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
@@ -436,20 +406,38 @@ impl IosCaptureState {
|
||||
speech: true,
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
if let Some(worker) = self.silero_coreml_worker.as_ref() {
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
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,
|
||||
)
|
||||
}
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
@@ -457,10 +445,10 @@ impl IosCaptureState {
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
@@ -483,13 +471,6 @@ 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 {
|
||||
@@ -527,7 +508,13 @@ 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;
|
||||
self.pcm_accum.extend_from_slice(&self.pre_roll_buf[idx]);
|
||||
if crate::capture_accumulator::append_i16_bounded(
|
||||
&mut self.pcm_accum,
|
||||
&self.pre_roll_buf[idx],
|
||||
) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if !transmit_active {
|
||||
// Gate closed — reset the flush flag so pre-roll fires again
|
||||
@@ -539,7 +526,9 @@ impl IosCaptureState {
|
||||
return;
|
||||
}
|
||||
|
||||
self.pcm_accum.extend_from_slice(&pcm_frame);
|
||||
if crate::capture_accumulator::append_i16_bounded(&mut self.pcm_accum, &pcm_frame) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,6 +539,13 @@ 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 {
|
||||
@@ -651,6 +647,44 @@ 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
|
||||
@@ -700,18 +734,7 @@ 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 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())?;
|
||||
let mut capture_state = IosCaptureState::new(¶ms)?;
|
||||
|
||||
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
// VPIO with our pinned stream format delivers
|
||||
@@ -737,13 +760,14 @@ 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 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.
|
||||
// 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.
|
||||
// 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
|
||||
@@ -772,126 +796,270 @@ impl IosVoiceUnit {
|
||||
//
|
||||
// Revert to direct call: render callback locks
|
||||
// AudioHandler, asks for `num_frames` stereo frames, and
|
||||
// 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]);
|
||||
}
|
||||
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}");
|
||||
}
|
||||
}
|
||||
// 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));
|
||||
|
||||
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,
|
||||
);
|
||||
#[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
|
||||
|
||||
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 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;
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
render_recorder_active = false;
|
||||
}
|
||||
} else {
|
||||
render_recorder_active = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
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;
|
||||
|
||||
// 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,
|
||||
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 {
|
||||
audio_processing_stats_for_render.increment_callback_xrun();
|
||||
}
|
||||
// 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,
|
||||
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
||||
muted,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
// Track audio-vs-silence for the diagnostic.
|
||||
if mix_stats.peak_i16 > 0 {
|
||||
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
||||
} else {
|
||||
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
||||
// Muted output writes intentional silence (peak_i16 == 0 by
|
||||
// design), not a starved render path. Gate on !muted to avoid
|
||||
// counting deliberate silence as an output underrun.
|
||||
if !muted {
|
||||
audio_processing_stats_for_render.increment_output_underrun();
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic sampling.
|
||||
if last_num_frames != 0 && last_num_frames != num_frames {
|
||||
num_frames_changes = num_frames_changes.wrapping_add(1);
|
||||
}
|
||||
last_num_frames = num_frames;
|
||||
cb_count = cb_count.wrapping_add(1);
|
||||
if cb_count.is_multiple_of(100) {
|
||||
debug!(
|
||||
target: "chanora_audio",
|
||||
cb = cb_count,
|
||||
num_frames,
|
||||
frames_changes = num_frames_changes,
|
||||
callbacks_with_audio,
|
||||
callbacks_with_silence,
|
||||
peak_out_i16 = mix_stats.peak_i16,
|
||||
gain,
|
||||
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||
} // end #[cfg(target_os = "ios")] block
|
||||
|
||||
// Finalise the unit — allocates internal buffers per the
|
||||
// stream formats we set above. After initialize() most
|
||||
@@ -994,7 +1162,11 @@ impl IosVoiceUnit {
|
||||
),
|
||||
}
|
||||
|
||||
Ok(Self { unit: Some(unit) })
|
||||
Ok(Self {
|
||||
unit: Some(unit),
|
||||
#[cfg(target_os = "macos")]
|
||||
producer_shutdown,
|
||||
})
|
||||
}
|
||||
|
||||
/// Restart the audio unit after route change handling.
|
||||
@@ -1038,6 +1210,11 @@ 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,7 +28,17 @@
|
||||
|
||||
#![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))]
|
||||
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;
|
||||
@@ -39,6 +49,11 @@ 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.
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(target_os = "ios", derive(Clone))]
|
||||
pub(crate) struct VoiceAudioParams {
|
||||
/// Opus-encoded voice packets sent on this channel toward the
|
||||
/// protocol layer.
|
||||
@@ -78,8 +78,18 @@ pub(crate) struct VoiceAudioParams {
|
||||
pub frames_sent: Arc<AtomicU32>,
|
||||
/// Pre-encode amplitude scale (1.0 = unity).
|
||||
pub mic_gain: f32,
|
||||
/// AudioHandler owned by the Android output callback.
|
||||
#[cfg(target_os = "android")]
|
||||
pub handler: AudioHandler<SessionAudioId>,
|
||||
/// Producer used by Android engine tasks to feed the output callback.
|
||||
#[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.
|
||||
/// 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).
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
|
||||
/// cross-thread read from the realtime audio callback).
|
||||
|
||||
@@ -3,15 +3,18 @@ use audiopus::{
|
||||
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
||||
SampleRate as OpusSampleRate,
|
||||
};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use crossbeam::queue::ArrayQueue;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, 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;
|
||||
@@ -54,10 +57,129 @@ pub(crate) fn tune_voip_encoder(encoder: &mut OpusEncoder, context: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) struct EncodedVoiceFrame {
|
||||
data: [u8; MAX_OPUS_FRAME],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct EncodedVoiceFrameSender {
|
||||
queue: Arc<ArrayQueue<EncodedVoiceFrame>>,
|
||||
open: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
enum EncodedVoiceFrameSendError {
|
||||
Full,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl EncodedVoiceFrameSender {
|
||||
fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
queue: Arc::new(ArrayQueue::new(capacity)),
|
||||
open: Arc::new(AtomicBool::new(true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_queue(&self) -> Arc<ArrayQueue<EncodedVoiceFrame>> {
|
||||
Arc::clone(&self.queue)
|
||||
}
|
||||
|
||||
fn worker_open_flag(&self) -> Arc<AtomicBool> {
|
||||
Arc::clone(&self.open)
|
||||
}
|
||||
|
||||
fn push(&self, frame: EncodedVoiceFrame) -> Result<(), EncodedVoiceFrameSendError> {
|
||||
if !self.open.load(Ordering::Relaxed) {
|
||||
return Err(EncodedVoiceFrameSendError::Closed);
|
||||
}
|
||||
self.queue
|
||||
.push(frame)
|
||||
.map_err(|_| EncodedVoiceFrameSendError::Full)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_out_packet_worker(
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
context: &'static str,
|
||||
) -> Result<EncodedVoiceFrameSender, AudioError> {
|
||||
start_out_packet_worker_with_spawner(voice_out_tx, frames_sent, context, |name, worker| {
|
||||
std::thread::Builder::new()
|
||||
.name(name)
|
||||
.spawn(worker)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
fn start_out_packet_worker_with_spawner<S>(
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
context: &'static str,
|
||||
spawn: S,
|
||||
) -> Result<EncodedVoiceFrameSender, AudioError>
|
||||
where
|
||||
S: FnOnce(String, Box<dyn FnOnce() + Send + 'static>) -> std::io::Result<()>,
|
||||
{
|
||||
let tx = EncodedVoiceFrameSender::new(VOICE_FRAME_QUEUE_CAPACITY);
|
||||
let rx = tx.worker_queue();
|
||||
let worker_open = tx.worker_open_flag();
|
||||
spawn(
|
||||
format!("chanora-{context}-voice-packets"),
|
||||
Box::new(move || {
|
||||
loop {
|
||||
let Some(frame) = rx.pop() else {
|
||||
if Arc::strong_count(&rx) == 1 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
continue;
|
||||
};
|
||||
let packet = OutAudio::new(&AudioData::C2S {
|
||||
id: 0,
|
||||
codec: CodecType::OpusVoice,
|
||||
data: frame.as_slice(),
|
||||
});
|
||||
match voice_out_tx.try_send(packet) {
|
||||
Ok(()) => {
|
||||
frames_sent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
warn!(target: "chanora_audio", context = %context, "voice_out queue full; dropping frame");
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
debug!(target: "chanora_audio", context = %context, "voice_out closed; voice packet worker stopping");
|
||||
worker_open.store(false, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.map_err(|e| {
|
||||
tx.open.store(false, Ordering::Relaxed);
|
||||
AudioError::Backend(format!("voice packet worker spawn ({context}): {e}"))
|
||||
})?;
|
||||
Ok(tx)
|
||||
}
|
||||
|
||||
impl EncodedVoiceFrame {
|
||||
fn try_from_opus(opus_out: &[u8], len: usize) -> Option<Self> {
|
||||
if len > opus_out.len() || len > MAX_OPUS_FRAME {
|
||||
return None;
|
||||
}
|
||||
let mut data = [0u8; MAX_OPUS_FRAME];
|
||||
data[..len].copy_from_slice(&opus_out[..len]);
|
||||
Some(Self { data, len })
|
||||
}
|
||||
|
||||
fn as_slice(&self) -> &[u8] {
|
||||
&self.data[..self.len]
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode-scope send helper for a freshly encoded Opus voice frame.
|
||||
pub(crate) fn send_voip_frame<F, G>(
|
||||
voice_out_tx: &mpsc::Sender<OutPacket>,
|
||||
frames_sent: &AtomicU32,
|
||||
voice_out_tx: &EncodedVoiceFrameSender,
|
||||
opus_out: &[u8],
|
||||
len: usize,
|
||||
on_full: F,
|
||||
@@ -66,16 +188,77 @@ pub(crate) fn send_voip_frame<F, G>(
|
||||
F: FnOnce(),
|
||||
G: FnOnce(),
|
||||
{
|
||||
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(),
|
||||
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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
use core::fmt;
|
||||
|
||||
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
|
||||
use thiserror::Error;
|
||||
|
||||
mod focused;
|
||||
|
||||
@@ -108,35 +109,51 @@ impl fmt::Display for PttInputClass {
|
||||
}
|
||||
|
||||
/// Errors raised by a desktop PTT backend.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Error)]
|
||||
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),
|
||||
}
|
||||
|
||||
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}"),
|
||||
}
|
||||
#[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 std::error::Error for PttBackendError {}
|
||||
|
||||
/// Cross-platform desktop PTT backend (SDD-081).
|
||||
///
|
||||
/// All implementations call exactly the audio transmit gate's
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
use std::array;
|
||||
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
const NO_LATEST_SLOT: usize = usize::MAX;
|
||||
|
||||
struct Slot<const SAMPLES: usize> {
|
||||
version: AtomicUsize,
|
||||
samples: [AtomicU32; SAMPLES],
|
||||
#[cfg(test)]
|
||||
bump_after_first_sample_read: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl<const SAMPLES: usize> Slot<SAMPLES> {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
version: AtomicUsize::new(0),
|
||||
samples: array::from_fn(|_| AtomicU32::new(0.0_f32.to_bits())),
|
||||
#[cfg(test)]
|
||||
bump_after_first_sample_read: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RenderReferenceFrameAccumulator<const SAMPLES: usize> {
|
||||
pending: [f32; SAMPLES],
|
||||
pending_len: usize,
|
||||
}
|
||||
|
||||
impl<const SAMPLES: usize> RenderReferenceFrameAccumulator<SAMPLES> {
|
||||
pub(crate) fn new() -> Self {
|
||||
assert!(
|
||||
SAMPLES > 0,
|
||||
"RenderReferenceFrameAccumulator requires at least one sample"
|
||||
);
|
||||
Self {
|
||||
pending: [0.0; SAMPLES],
|
||||
pending_len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn push_mono_samples(
|
||||
&mut self,
|
||||
mut samples: &[f32],
|
||||
mut publish: impl FnMut(&[f32; SAMPLES]),
|
||||
) {
|
||||
while !samples.is_empty() {
|
||||
let needed = SAMPLES - self.pending_len;
|
||||
let take = needed.min(samples.len());
|
||||
self.pending[self.pending_len..self.pending_len + take]
|
||||
.copy_from_slice(&samples[..take]);
|
||||
self.pending_len += take;
|
||||
samples = &samples[take..];
|
||||
|
||||
if self.pending_len == SAMPLES {
|
||||
publish(&self.pending);
|
||||
self.pending_len = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn pending_len(&self) -> usize {
|
||||
self.pending_len
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RenderReferenceBuffer<const SAMPLES: usize, const SLOTS: usize> {
|
||||
slots: Box<[Slot<SAMPLES>; SLOTS]>,
|
||||
write_idx: AtomicUsize,
|
||||
latest_slot: AtomicUsize,
|
||||
}
|
||||
|
||||
impl<const SAMPLES: usize, const SLOTS: usize> RenderReferenceBuffer<SAMPLES, SLOTS> {
|
||||
pub(crate) fn new() -> Arc<Self> {
|
||||
assert!(
|
||||
SLOTS > 0,
|
||||
"RenderReferenceBuffer requires at least one slot"
|
||||
);
|
||||
Arc::new(Self {
|
||||
slots: Box::new(array::from_fn(|_| Slot::new())),
|
||||
write_idx: AtomicUsize::new(0),
|
||||
latest_slot: AtomicUsize::new(NO_LATEST_SLOT),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn write(&self, frame: &[f32; SAMPLES]) {
|
||||
let idx = self.write_idx.load(Ordering::Relaxed) % SLOTS;
|
||||
let slot = &self.slots[idx];
|
||||
// The acquire half keeps payload stores after the odd in-progress marker.
|
||||
let version = slot.version.fetch_add(1, Ordering::AcqRel);
|
||||
debug_assert_eq!(version & 1, 0, "single writer should only enter even slots");
|
||||
for (sample, value) in slot.samples.iter().zip(frame.iter().copied()) {
|
||||
sample.store(value.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
slot.version
|
||||
.store(version.wrapping_add(2) & !1, Ordering::Release);
|
||||
self.latest_slot.store(idx, Ordering::Release);
|
||||
self.write_idx.store((idx + 1) % SLOTS, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn read_latest(&self) -> [f32; SAMPLES] {
|
||||
let mut out = [0.0_f32; SAMPLES];
|
||||
self.read_latest_into(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn read_latest_into(&self, out: &mut [f32; SAMPLES]) {
|
||||
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||
if idx == NO_LATEST_SLOT {
|
||||
out.fill(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
let slot = &self.slots[idx];
|
||||
let before = slot.version.load(Ordering::Acquire);
|
||||
if before & 1 == 1 {
|
||||
out.fill(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
for (dst, sample) in out.iter_mut().zip(slot.samples.iter()) {
|
||||
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
for (idx, (dst, sample)) in out.iter_mut().zip(slot.samples.iter()).enumerate() {
|
||||
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
|
||||
if idx == 0
|
||||
&& slot
|
||||
.bump_after_first_sample_read
|
||||
.swap(false, Ordering::Relaxed)
|
||||
{
|
||||
slot.version.fetch_add(2, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
let after = slot.version.load(Ordering::Acquire);
|
||||
if before != after || after & 1 == 1 {
|
||||
out.fill(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn mark_latest_slot_in_progress_for_test(&self) {
|
||||
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||
assert_ne!(idx, NO_LATEST_SLOT);
|
||||
self.slots[idx].version.fetch_or(1, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn bump_latest_slot_version_after_first_sample_for_test(&self) {
|
||||
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||
assert_ne!(idx, NO_LATEST_SLOT);
|
||||
self.slots[idx]
|
||||
.bump_after_first_sample_read
|
||||
.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{RenderReferenceBuffer, RenderReferenceFrameAccumulator};
|
||||
|
||||
#[test]
|
||||
fn render_reference_reads_zero_before_first_publish() {
|
||||
let buffer = RenderReferenceBuffer::<4, 2>::new();
|
||||
|
||||
assert_eq!(buffer.read_latest(), [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_reader_gets_latest_complete_frame() {
|
||||
let buffer = RenderReferenceBuffer::<4, 3>::new();
|
||||
|
||||
buffer.write(&[1.0, 2.0, 3.0, 4.0]);
|
||||
buffer.write(&[5.0, 6.0, 7.0, 8.0]);
|
||||
|
||||
assert_eq!(buffer.read_latest(), [5.0, 6.0, 7.0, 8.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_writes_wrap_without_returning_stale_frame() {
|
||||
let buffer = RenderReferenceBuffer::<2, 2>::new();
|
||||
|
||||
buffer.write(&[1.0, 2.0]);
|
||||
buffer.write(&[3.0, 4.0]);
|
||||
buffer.write(&[5.0, 6.0]);
|
||||
|
||||
assert_eq!(buffer.read_latest(), [5.0, 6.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_accumulator_publishes_only_complete_frames() {
|
||||
let mut accum = RenderReferenceFrameAccumulator::<4>::new();
|
||||
let mut frames = Vec::new();
|
||||
|
||||
accum.push_mono_samples(&[1.0, 2.0], |frame| frames.push(*frame));
|
||||
|
||||
assert!(frames.is_empty());
|
||||
assert_eq!(accum.pending_len(), 2);
|
||||
|
||||
accum.push_mono_samples(&[3.0, 4.0, 5.0, 6.0, 7.0], |frame| frames.push(*frame));
|
||||
|
||||
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0]]);
|
||||
assert_eq!(accum.pending_len(), 3);
|
||||
|
||||
accum.push_mono_samples(&[8.0], |frame| frames.push(*frame));
|
||||
|
||||
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]]);
|
||||
assert_eq!(accum.pending_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_reader_rejects_in_progress_slot() {
|
||||
let buffer = RenderReferenceBuffer::<2, 1>::new();
|
||||
|
||||
buffer.write(&[1.0, 2.0]);
|
||||
buffer.mark_latest_slot_in_progress_for_test();
|
||||
|
||||
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_reader_rejects_stale_slot_changed_during_read() {
|
||||
let buffer = RenderReferenceBuffer::<2, 1>::new();
|
||||
|
||||
buffer.write(&[1.0, 2.0]);
|
||||
|
||||
buffer.bump_latest_slot_version_after_first_sample_for_test();
|
||||
|
||||
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "RenderReferenceBuffer requires at least one slot")]
|
||||
fn render_reference_rejects_zero_slots() {
|
||||
let _ = RenderReferenceBuffer::<2, 0>::new();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
use crate::{AudioError, VadBackend};
|
||||
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
@@ -108,6 +108,36 @@ 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);
|
||||
|
||||
@@ -252,4 +282,32 @@ 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,4 +1,5 @@
|
||||
/// 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.
|
||||
@@ -7,37 +8,45 @@ pub(crate) struct RenderDownmixStats {
|
||||
pub clipped_samples: u64,
|
||||
}
|
||||
|
||||
/// Downmix interleaved stereo f32 samples into mono i16 samples.
|
||||
///
|
||||
/// The helper is allocation-free and safe for realtime render callbacks.
|
||||
/// If the stereo source is shorter than expected, the remainder of `out`
|
||||
/// is filled with silence.
|
||||
pub(crate) fn downmix_stereo_f32_to_mono_i16(
|
||||
#[cfg(any(target_os = "ios", test))]
|
||||
pub(crate) fn downmix_stereo_f32_to_interleaved_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;
|
||||
if available_frames < out.len() {
|
||||
let requested_frames = out.len() / out_channels;
|
||||
if available_frames < requested_frames {
|
||||
out.fill(0);
|
||||
}
|
||||
|
||||
let mut peak = 0_u16;
|
||||
let mut clipped_samples = 0_u64;
|
||||
for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) {
|
||||
for (dst_frame, lr) in out
|
||||
.chunks_exact_mut(out_channels)
|
||||
.zip(stereo.chunks_exact(2))
|
||||
{
|
||||
let mono = (lr[0] + lr[1]) * 0.5 * gain;
|
||||
let clamped = mono.clamp(-1.0, 1.0);
|
||||
if (mono - clamped).abs() > f32::EPSILON {
|
||||
clipped_samples = clipped_samples.saturating_add(1);
|
||||
}
|
||||
let sample = (clamped * i16::MAX as f32) as i16;
|
||||
*dst = sample;
|
||||
for dst in dst_frame.iter_mut() {
|
||||
*dst = sample;
|
||||
}
|
||||
peak = peak.max(sample.unsigned_abs());
|
||||
}
|
||||
|
||||
@@ -62,6 +71,29 @@ 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::*;
|
||||
@@ -71,7 +103,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_mono_i16(&stereo, &mut out, 2.0, false);
|
||||
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 2.0, false);
|
||||
|
||||
assert_eq!(out[0], i16::MAX);
|
||||
assert_eq!(out[1], 0);
|
||||
@@ -85,12 +117,35 @@ 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_mono_i16(&stereo, &mut out, 1.0, true);
|
||||
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 1.0, true);
|
||||
|
||||
assert_eq!(out, [0, 0]);
|
||||
assert_eq!(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];
|
||||
@@ -100,4 +155,45 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1038,6 +1038,8 @@ pub struct BridgeAudioStats {
|
||||
pub frames_received: u32,
|
||||
/// Current push-to-talk state.
|
||||
pub ptt_active: bool,
|
||||
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
|
||||
pub input_level: f32,
|
||||
}
|
||||
|
||||
/// Bridge route class for P1 audio-processing policy.
|
||||
@@ -1701,49 +1703,87 @@ pub enum BridgeEvent {
|
||||
},
|
||||
/// Audio route changed (speaker/earpiece/BT/wired).
|
||||
AudioRouteChanged {
|
||||
/// New audio output route.
|
||||
route: BridgeAudioRoute,
|
||||
},
|
||||
/// 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,
|
||||
/// True for server query (bot) clients.
|
||||
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,
|
||||
/// True for server query (bot) clients.
|
||||
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; `None` means no restriction.
|
||||
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; `None` means no restriction.
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
}
|
||||
@@ -1932,27 +1972,77 @@ 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2058,7 +2148,7 @@ pub fn events_stream(sink: StreamSink<BridgeEvent>) -> Result<(), BridgeError> {
|
||||
|
||||
/// Read audio statistics. Errors if no connection or audio not started.
|
||||
pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
||||
let (s, r, p) = runtime()
|
||||
let (s, r, p, lvl) = runtime()
|
||||
.spawn(async { session().audio_stats().await })
|
||||
.await
|
||||
.map_err(|e| task_join_error("audio_stats", e))??;
|
||||
@@ -2066,9 +2156,42 @@ pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
||||
frames_sent: s,
|
||||
frames_received: r,
|
||||
ptt_active: p,
|
||||
input_level: lvl,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn input_level_stream(sink: StreamSink<f32>) -> Result<(), BridgeError> {
|
||||
runtime().spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(33));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut consecutive_errors = 0u32;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let level = match session().audio_stats().await {
|
||||
Ok((_, _, _, lvl)) => {
|
||||
consecutive_errors = 0;
|
||||
lvl
|
||||
}
|
||||
Err(_) => {
|
||||
consecutive_errors += 1;
|
||||
if consecutive_errors >= 10 {
|
||||
return;
|
||||
}
|
||||
-120.0
|
||||
}
|
||||
};
|
||||
if sink.add(level).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply the P1 audio-processing config.
|
||||
pub async fn set_audio_processing_config(
|
||||
config: BridgeAudioProcessingConfig,
|
||||
|
||||
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
||||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||
);
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 281698435;
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -20394775;
|
||||
|
||||
// Section: executor
|
||||
|
||||
@@ -738,6 +738,42 @@ fn wire__crate__api__init_storage_impl(
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__input_level_stream_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
|
||||
flutter_rust_bridge::for_generated::TaskInfo {
|
||||
debug_name: "input_level_stream",
|
||||
port: Some(port_),
|
||||
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||
},
|
||||
move || {
|
||||
let message = unsafe {
|
||||
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||
ptr_,
|
||||
rust_vec_len_,
|
||||
data_len_,
|
||||
)
|
||||
};
|
||||
let mut deserializer =
|
||||
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||
let api_sink =
|
||||
<StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec>>::sse_decode(
|
||||
&mut deserializer,
|
||||
);
|
||||
deserializer.end();
|
||||
move |context| {
|
||||
transform_result_sse::<_, crate::BridgeError>((move || {
|
||||
let output_ok = crate::api::input_level_stream(api_sink)?;
|
||||
Ok(output_ok)
|
||||
})())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__is_connected_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
@@ -1790,6 +1826,14 @@ impl SseDecode
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec> {
|
||||
// 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 = <String>::sse_decode(deserializer);
|
||||
return StreamSink::deserialize(inner);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for String {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
@@ -1962,10 +2006,12 @@ impl SseDecode for crate::api::BridgeAudioStats {
|
||||
let mut var_framesSent = <u32>::sse_decode(deserializer);
|
||||
let mut var_framesReceived = <u32>::sse_decode(deserializer);
|
||||
let mut var_pttActive = <bool>::sse_decode(deserializer);
|
||||
let mut var_inputLevel = <f32>::sse_decode(deserializer);
|
||||
return crate::api::BridgeAudioStats {
|
||||
frames_sent: var_framesSent,
|
||||
frames_received: var_framesReceived,
|
||||
ptt_active: var_pttActive,
|
||||
input_level: var_inputLevel,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2755,33 +2801,34 @@ fn pde_ffi_dispatcher_primary_impl(
|
||||
14 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||
15 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||
20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
|
||||
21 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
||||
22 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
|
||||
23 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
|
||||
25 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
|
||||
26 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
|
||||
27 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
|
||||
29 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
|
||||
31 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
|
||||
32 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
|
||||
33 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
|
||||
34 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
|
||||
35 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||
36 => {
|
||||
21 => wire__crate__api__input_level_stream_impl(port, ptr, rust_vec_len, data_len),
|
||||
22 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
||||
23 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
|
||||
24 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
|
||||
26 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
|
||||
27 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
|
||||
28 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
|
||||
30 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
|
||||
32 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
|
||||
33 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
|
||||
34 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
|
||||
35 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
|
||||
36 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||
37 => {
|
||||
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
|
||||
}
|
||||
38 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
|
||||
39 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
|
||||
40 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||
41 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
||||
42 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
||||
43 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||
44 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||
45 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
|
||||
46 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
||||
47 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||
48 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
|
||||
49 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
|
||||
39 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
|
||||
40 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
|
||||
41 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||
42 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
||||
43 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
||||
44 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||
45 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||
46 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
|
||||
47 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
||||
48 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||
49 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
|
||||
50 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -2803,10 +2850,10 @@ fn pde_ffi_dispatcher_sync_impl(
|
||||
data_len,
|
||||
),
|
||||
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
|
||||
24 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
|
||||
28 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
|
||||
30 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
|
||||
37 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
|
||||
25 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
|
||||
29 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
|
||||
31 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
|
||||
38 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -2984,6 +3031,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioStats {
|
||||
self.frames_sent.into_into_dart().into_dart(),
|
||||
self.frames_received.into_into_dart().into_dart(),
|
||||
self.ptt_active.into_into_dart().into_dart(),
|
||||
self.input_level.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart()
|
||||
}
|
||||
@@ -3652,6 +3700,13 @@ impl SseEncode
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec> {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
unimplemented!("")
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for String {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
@@ -3781,6 +3836,7 @@ impl SseEncode for crate::api::BridgeAudioStats {
|
||||
<u32>::sse_encode(self.frames_sent, serializer);
|
||||
<u32>::sse_encode(self.frames_received, serializer);
|
||||
<bool>::sse_encode(self.ptt_active, serializer);
|
||||
<f32>::sse_encode(self.input_level, serializer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
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: Vec<String>,
|
||||
events: VecDeque<String>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
@@ -720,17 +720,20 @@ impl ProtocolEventRecorder {
|
||||
/// Create a recorder with the given ring-buffer capacity.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
events: Vec::with_capacity(capacity),
|
||||
events: VecDeque::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.remove(0);
|
||||
self.events.pop_front();
|
||||
}
|
||||
self.events.push(s);
|
||||
self.events.push_back(s);
|
||||
}
|
||||
|
||||
/// Record a successful connection.
|
||||
@@ -777,12 +780,12 @@ impl ProtocolEventRecorder {
|
||||
|
||||
/// Drain all recorded events and reset the buffer.
|
||||
pub fn drain(&mut self) -> Vec<String> {
|
||||
std::mem::take(&mut self.events)
|
||||
self.events.drain(..).collect()
|
||||
}
|
||||
|
||||
/// Snapshot all recorded events without clearing the buffer.
|
||||
pub fn snapshot(&self) -> Vec<String> {
|
||||
self.events.clone()
|
||||
self.events.iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1103,4 +1106,13 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ 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,
|
||||
@@ -56,6 +59,13 @@ type PendingMoves = HashMap<
|
||||
),
|
||||
>;
|
||||
|
||||
struct EventChannels {
|
||||
voice_in: mpsc::Sender<InboundVoice>,
|
||||
chat: mpsc::Sender<ChatMessage>,
|
||||
activity: mpsc::Sender<ServerActivity>,
|
||||
delta: mpsc::Sender<ProtocolDelta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum SendTimeoutError<T> {
|
||||
Timeout(T),
|
||||
@@ -77,6 +87,30 @@ 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*
|
||||
@@ -278,10 +312,12 @@ impl ProtocolClient {
|
||||
cfg.clone(),
|
||||
rx,
|
||||
voice_out_rx,
|
||||
voice_in_tx,
|
||||
chat_tx,
|
||||
activity_tx,
|
||||
delta_tx,
|
||||
EventChannels {
|
||||
voice_in: voice_in_tx,
|
||||
chat: chat_tx,
|
||||
activity: activity_tx,
|
||||
delta: delta_tx,
|
||||
},
|
||||
ready_tx,
|
||||
lost_tx,
|
||||
));
|
||||
@@ -332,8 +368,21 @@ impl ProtocolClient {
|
||||
/// Disconnect cleanly. Blocks until the task exits.
|
||||
pub async fn disconnect(self) {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if self.tx.send(Request::Disconnect(tx)).await.is_ok() {
|
||||
let _ = rx.await;
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,6 +520,9 @@ impl ProtocolClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes ownership of the delta receiver channel. Returns `None` if already
|
||||
/// taken. Must be called exactly once during initialization to subscribe to
|
||||
/// incremental state changes.
|
||||
pub fn take_delta_rx(&self) -> Option<mpsc::Receiver<ProtocolDelta>> {
|
||||
self.delta_rx.lock().ok().and_then(|mut g| g.take())
|
||||
}
|
||||
@@ -499,10 +551,7 @@ async fn connection_task(
|
||||
cfg: ConnectConfig,
|
||||
mut rx: mpsc::Receiver<Request>,
|
||||
mut voice_out_rx: mpsc::Receiver<OutPacket>,
|
||||
voice_in_tx: mpsc::Sender<InboundVoice>,
|
||||
chat_tx: mpsc::Sender<ChatMessage>,
|
||||
activity_tx: mpsc::Sender<ServerActivity>,
|
||||
delta_tx: mpsc::Sender<ProtocolDelta>,
|
||||
channels: EventChannels,
|
||||
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
|
||||
lost_tx: oneshot::Sender<DisconnectReason>,
|
||||
) {
|
||||
@@ -667,12 +716,14 @@ async fn connection_task(
|
||||
|
||||
// Main loop: pump events, service requests, forward voice.
|
||||
loop {
|
||||
// 1. Drain any outbound voice packets first — they're time-sensitive.
|
||||
while let Ok(pkt) = voice_out_rx.try_recv() {
|
||||
// 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| {
|
||||
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 {
|
||||
@@ -680,21 +731,19 @@ 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(&voice_in_tx, &mut voice_activity, buf).await;
|
||||
}
|
||||
other => handle_non_audio_stream_item(
|
||||
&con,
|
||||
other,
|
||||
&chat_tx,
|
||||
&activity_tx,
|
||||
&delta_tx,
|
||||
&mut pending_moves,
|
||||
),
|
||||
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,
|
||||
),
|
||||
},
|
||||
Ok(Some(Err(e))) => {
|
||||
warn!(target: "chanora_protocol", error = %e, "event error");
|
||||
// Some errors are transient; treat persistent ones
|
||||
@@ -727,10 +776,8 @@ async fn connection_task(
|
||||
})
|
||||
.collect();
|
||||
for handle in expired {
|
||||
if let Some((_target_channel, reply, _)) = pending_moves.remove(&handle) {
|
||||
if let Some(reply) = reply {
|
||||
let _ = reply.send(Ok(()));
|
||||
}
|
||||
if let Some((_target_channel, Some(reply), _)) = pending_moves.remove(&handle) {
|
||||
let _ = reply.send(Ok(()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -791,10 +838,7 @@ async fn connection_task(
|
||||
let r = fetch_client_profile(
|
||||
&mut con,
|
||||
client_id,
|
||||
&voice_in_tx,
|
||||
&chat_tx,
|
||||
&activity_tx,
|
||||
&delta_tx,
|
||||
&channels,
|
||||
&mut pending_moves,
|
||||
&mut voice_activity,
|
||||
)
|
||||
@@ -803,7 +847,7 @@ async fn connection_task(
|
||||
}
|
||||
Ok(Request::Disconnect(reply)) => {
|
||||
let _ = con.disconnect(DisconnectOptions::new());
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
||||
let _ = reply.send(());
|
||||
info!(target: "chanora_protocol", "clean disconnect");
|
||||
exit!(DisconnectReason::UserRequested);
|
||||
@@ -811,7 +855,7 @@ async fn connection_task(
|
||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
let _ = con.disconnect(DisconnectOptions::new());
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
||||
info!(target: "chanora_protocol", "handle dropped; implicit disconnect");
|
||||
exit!(DisconnectReason::UserRequested);
|
||||
}
|
||||
@@ -869,7 +913,7 @@ fn handle_non_audio_stream_item(
|
||||
} = &ev
|
||||
{
|
||||
let own_client = con.get_state().ok().map(|state| state.own_client);
|
||||
if let Some(state) = con.get_state().ok() {
|
||||
if let Ok(state) = con.get_state() {
|
||||
if let Some(client) = state.clients.get(client_id) {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ClientMoved {
|
||||
client_id: client_id.0 as u64,
|
||||
@@ -923,7 +967,9 @@ fn handle_non_audio_stream_item(
|
||||
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::Client(id) => {
|
||||
MessageTarget::Client(id.0 as u64)
|
||||
}
|
||||
tsclientlib::MessageTarget::Poke(id) => MessageTarget::Poke(id.0 as u64),
|
||||
};
|
||||
let _ = chat_tx.try_send(ChatMessage {
|
||||
@@ -1115,16 +1161,21 @@ fn send_text_to_mode(
|
||||
async fn fetch_client_profile(
|
||||
con: &mut Connection,
|
||||
client_id: u64,
|
||||
voice_in_tx: &mpsc::Sender<InboundVoice>,
|
||||
chat_tx: &mpsc::Sender<ChatMessage>,
|
||||
activity_tx: &mpsc::Sender<ServerActivity>,
|
||||
delta_tx: &mpsc::Sender<ProtocolDelta>,
|
||||
channels: &EventChannels,
|
||||
pending_moves: &mut PendingMoves,
|
||||
voice_activity: &mut HashMap<u64, Instant>,
|
||||
) -> 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}")))?;
|
||||
@@ -1155,10 +1206,7 @@ async fn fetch_client_profile(
|
||||
let _ = request_messages(
|
||||
con,
|
||||
build_command("servergrouplist", &[], &[]),
|
||||
voice_in_tx,
|
||||
chat_tx,
|
||||
activity_tx,
|
||||
delta_tx,
|
||||
channels,
|
||||
pending_moves,
|
||||
voice_activity,
|
||||
)
|
||||
@@ -1168,10 +1216,7 @@ async fn fetch_client_profile(
|
||||
let _ = request_messages(
|
||||
con,
|
||||
build_command("channelgrouplist", &[], &[]),
|
||||
voice_in_tx,
|
||||
chat_tx,
|
||||
activity_tx,
|
||||
delta_tx,
|
||||
channels,
|
||||
pending_moves,
|
||||
voice_activity,
|
||||
)
|
||||
@@ -1185,10 +1230,7 @@ async fn fetch_client_profile(
|
||||
&[("clid", client_id.to_string())],
|
||||
&[],
|
||||
),
|
||||
voice_in_tx,
|
||||
chat_tx,
|
||||
activity_tx,
|
||||
delta_tx,
|
||||
channels,
|
||||
pending_moves,
|
||||
voice_activity,
|
||||
)
|
||||
@@ -1206,10 +1248,7 @@ async fn fetch_client_profile(
|
||||
if let Err(e) = request_messages(
|
||||
con,
|
||||
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
|
||||
voice_in_tx,
|
||||
chat_tx,
|
||||
activity_tx,
|
||||
delta_tx,
|
||||
channels,
|
||||
pending_moves,
|
||||
voice_activity,
|
||||
)
|
||||
@@ -1225,18 +1264,9 @@ async fn fetch_client_profile(
|
||||
}
|
||||
|
||||
let db_info = if refresh_plan.needs_client_db_info {
|
||||
request_client_db_info(
|
||||
con,
|
||||
database_id,
|
||||
voice_in_tx,
|
||||
chat_tx,
|
||||
activity_tx,
|
||||
delta_tx,
|
||||
pending_moves,
|
||||
voice_activity,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
request_client_db_info(con, database_id, channels, pending_moves, voice_activity)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1303,10 +1333,18 @@ 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)),
|
||||
@@ -1339,14 +1377,10 @@ 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)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1389,10 +1423,7 @@ fn client_profile_refresh_plan(
|
||||
async fn request_messages(
|
||||
con: &mut Connection,
|
||||
command: OutCommand,
|
||||
voice_in_tx: &mpsc::Sender<InboundVoice>,
|
||||
chat_tx: &mpsc::Sender<ChatMessage>,
|
||||
activity_tx: &mpsc::Sender<ServerActivity>,
|
||||
delta_tx: &mpsc::Sender<ProtocolDelta>,
|
||||
channels: &EventChannels,
|
||||
pending_moves: &mut PendingMoves,
|
||||
voice_activity: &mut HashMap<u64, Instant>,
|
||||
) -> Result<Vec<InMessage>, ProtocolError> {
|
||||
@@ -1428,14 +1459,14 @@ async fn request_messages(
|
||||
return Ok(messages);
|
||||
}
|
||||
StreamItem::Audio(buf) => {
|
||||
handle_audio_stream_item(voice_in_tx, voice_activity, buf).await;
|
||||
handle_audio_stream_item(&channels.voice_in, voice_activity, buf).await;
|
||||
}
|
||||
other => handle_non_audio_stream_item(
|
||||
con,
|
||||
other,
|
||||
chat_tx,
|
||||
activity_tx,
|
||||
delta_tx,
|
||||
&channels.chat,
|
||||
&channels.activity,
|
||||
&channels.delta,
|
||||
pending_moves,
|
||||
),
|
||||
}
|
||||
@@ -1445,20 +1476,14 @@ async fn request_messages(
|
||||
async fn request_client_db_info(
|
||||
con: &mut Connection,
|
||||
dbid: tsclientlib::ClientDbId,
|
||||
voice_in_tx: &mpsc::Sender<InboundVoice>,
|
||||
chat_tx: &mpsc::Sender<ChatMessage>,
|
||||
activity_tx: &mpsc::Sender<ServerActivity>,
|
||||
delta_tx: &mpsc::Sender<ProtocolDelta>,
|
||||
channels: &EventChannels,
|
||||
pending_moves: &mut PendingMoves,
|
||||
voice_activity: &mut HashMap<u64, Instant>,
|
||||
) -> Result<InClientDbInfoPart, ProtocolError> {
|
||||
let messages = request_messages(
|
||||
con,
|
||||
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
|
||||
voice_in_tx,
|
||||
chat_tx,
|
||||
activity_tx,
|
||||
delta_tx,
|
||||
channels,
|
||||
pending_moves,
|
||||
voice_activity,
|
||||
)
|
||||
@@ -1781,9 +1806,7 @@ fn format_server_activity(con: &Connection, ev: &tsclientlib::events::Event) ->
|
||||
extra,
|
||||
..
|
||||
} => {
|
||||
if extra.reason.is_none() {
|
||||
return None;
|
||||
}
|
||||
extra.reason?;
|
||||
let client = activity_client(con, *client_id)?;
|
||||
let channel = activity_channel_name(con, client.channel)?;
|
||||
Some(format!(
|
||||
@@ -1901,10 +1924,12 @@ const _: () = {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
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,
|
||||
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,
|
||||
};
|
||||
use futures::stream;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tsproto_types::ClientType;
|
||||
@@ -2147,6 +2172,83 @@ 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(
|
||||
@@ -2161,7 +2263,7 @@ fn forward_delta(
|
||||
id: PropertyId::Client(client_id),
|
||||
..
|
||||
} => {
|
||||
if let Some(state) = con.get_state().ok() {
|
||||
if let Ok(state) = con.get_state() {
|
||||
if let Some(client) = state.clients.get(client_id) {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ClientJoined {
|
||||
client_id: client_id.0 as u64,
|
||||
@@ -2178,15 +2280,13 @@ fn forward_delta(
|
||||
}
|
||||
Event::PropertyRemoved {
|
||||
id: PropertyId::Client(_),
|
||||
old,
|
||||
old: PropertyValue::Client(client),
|
||||
..
|
||||
} => {
|
||||
if let PropertyValue::Client(client) = old {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ClientLeft {
|
||||
client_id: client.id.0 as u64,
|
||||
name: client.name.clone(),
|
||||
});
|
||||
}
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ClientLeft {
|
||||
client_id: client.id.0 as u64,
|
||||
name: client.name.clone(),
|
||||
});
|
||||
}
|
||||
Event::PropertyChanged {
|
||||
id: PropertyId::ClientChannel(_),
|
||||
@@ -2196,7 +2296,7 @@ fn forward_delta(
|
||||
id: PropertyId::Client(client_id),
|
||||
..
|
||||
} => {
|
||||
if let Some(state) = con.get_state().ok() {
|
||||
if let Ok(state) = con.get_state() {
|
||||
if let Some(client) = state.clients.get(client_id) {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ClientUpdated {
|
||||
client_id: client_id.0 as u64,
|
||||
@@ -2213,7 +2313,7 @@ fn forward_delta(
|
||||
id: PropertyId::Channel(channel_id),
|
||||
..
|
||||
} => {
|
||||
if let Some(state) = con.get_state().ok() {
|
||||
if let Ok(state) = con.get_state() {
|
||||
if let Some(channel) = state.channels.get(channel_id) {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelAdded {
|
||||
id: channel_id.0,
|
||||
@@ -2228,20 +2328,16 @@ fn forward_delta(
|
||||
}
|
||||
Event::PropertyRemoved {
|
||||
id: PropertyId::Channel(_),
|
||||
old,
|
||||
old: PropertyValue::Channel(channel),
|
||||
..
|
||||
} => {
|
||||
if let PropertyValue::Channel(channel) = old {
|
||||
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),
|
||||
..
|
||||
} => {
|
||||
if let Some(state) = con.get_state().ok() {
|
||||
if let Ok(state) = con.get_state() {
|
||||
if let Some(channel) = state.channels.get(channel_id) {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelUpdated {
|
||||
id: channel_id.0,
|
||||
|
||||
@@ -168,52 +168,93 @@ pub struct ServerSnapshot {
|
||||
}
|
||||
|
||||
impl ChannelId {
|
||||
/// Root/top-level channel identifier.
|
||||
pub const ROOT: ChannelId = ChannelId(0);
|
||||
}
|
||||
|
||||
/// Incremental state change emitted by the protocol adapter when the server tree changes.
|
||||
///
|
||||
/// Covers client joins, leaves, moves, and channel additions, removals, and updates.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProtocolDelta {
|
||||
/// A client moved to a different channel.
|
||||
ClientMoved {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Destination channel identifier.
|
||||
new_channel_id: u64,
|
||||
},
|
||||
/// A new client appeared on the server.
|
||||
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 the client is a server query 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.
|
||||
name: String,
|
||||
},
|
||||
/// A client's properties changed.
|
||||
ClientUpdated {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Microphone muted state.
|
||||
input_muted: bool,
|
||||
/// Speaker muted state.
|
||||
output_muted: bool,
|
||||
/// Whether the client is a server query 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 {
|
||||
/// Channel identifier.
|
||||
id: u64,
|
||||
/// Parent channel identifier.
|
||||
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 has a password.
|
||||
has_password: bool,
|
||||
/// Needed talk power, or `None` when unrestricted.
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
/// A channel was deleted.
|
||||
ChannelRemoved {
|
||||
/// Channel identifier.
|
||||
id: u64,
|
||||
},
|
||||
/// Channel properties changed.
|
||||
ChannelUpdated {
|
||||
/// Channel identifier.
|
||||
id: u64,
|
||||
/// Channel name.
|
||||
name: String,
|
||||
/// Whether the channel has a password.
|
||||
has_password: bool,
|
||||
/// Needed talk power, or `None` when unrestricted.
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "chanora_resolver"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -346,9 +346,7 @@ pub fn reduce(state: &mut Option<ServerState>, event: StateEvent) -> Reduction {
|
||||
if removed_channel {
|
||||
deltas.push(Delta::ChannelRemoved(id));
|
||||
}
|
||||
Reduction {
|
||||
deltas,
|
||||
}
|
||||
Reduction { deltas }
|
||||
}
|
||||
_ => Reduction { deltas: vec![] },
|
||||
},
|
||||
@@ -412,14 +410,7 @@ pub fn reduce_reconnect_snapshot(
|
||||
state: &mut Option<ServerState>,
|
||||
snap: ServerSnapshot,
|
||||
) -> Reduction {
|
||||
let normalized = normalize_snapshot(snap);
|
||||
*state = Some(ServerState::from_snapshot(normalized.clone()));
|
||||
Reduction {
|
||||
deltas: vec![
|
||||
Delta::ConnectionStateChanged(ConnectionState::Ready),
|
||||
Delta::SnapshotApplied(normalized),
|
||||
],
|
||||
}
|
||||
reduce(state, StateEvent::Snapshot(snap))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user