Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0060f3c19 | ||
|
|
8cb5a7a258 | ||
|
|
484dad1072 |
-17
@@ -117,23 +117,6 @@ opencode.json
|
|||||||
|
|
||||||
# iOS framework build artifacts produced by chanora_bridge.podspec
|
# iOS framework build artifacts produced by chanora_bridge.podspec
|
||||||
/apps/chanora_flutter/ios/Frameworks/
|
/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/
|
.opencode/
|
||||||
.omo/
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
Screenshot 2026-05-17 at 22.23.07.png
|
Screenshot 2026-05-17 at 22.23.07.png
|
||||||
|
|
||||||
# Xcode archive / export bundles (generated by Product > Archive > Distribute)
|
|
||||||
**/Chanora */
|
|
||||||
**/*.xcarchive/
|
|
||||||
**/*.ipa
|
|
||||||
**/*.dSYM/
|
|
||||||
|
|
||||||
# macOS release zip bundles produced by local release scripts
|
|
||||||
/chanora-v*.zip
|
|
||||||
|
|
||||||
|
|||||||
+12
-16
@@ -8,10 +8,8 @@ This project follows a Conventional Commits style workflow.
|
|||||||
|
|
||||||
The v0.3.0 milestone transitions Chanora from an internal-beta voice
|
The v0.3.0 milestone transitions Chanora from an internal-beta voice
|
||||||
prototype to a cross-platform baseline client with event-driven UI,
|
prototype to a cross-platform baseline client with event-driven UI,
|
||||||
visible per-client audio state, non-self client info parity, and
|
per-user audio controls, non-self client info parity, and CI-hardened
|
||||||
documented host Rust workspace plus Flutter validation gates. Android
|
Android / iOS / macOS / Linux builds.
|
||||||
target compile/install/smoke evidence remains blocked locally pending the
|
|
||||||
required NDK compiler and an authorized ADB target.
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -19,9 +17,10 @@ required NDK compiler and an authorized ADB target.
|
|||||||
deltas (client join/leave/move/update, channel add/remove/update)
|
deltas (client join/leave/move/update, channel add/remove/update)
|
||||||
flow through a typed `ProtocolDelta` enum and update the Flutter UI
|
flow through a typed `ProtocolDelta` enum and update the Flutter UI
|
||||||
in real time. Channel switching is instant.
|
in real time. Channel switching is instant.
|
||||||
- **Per-client audio state visibility.** Client rows surface
|
- **Per-user volume controls.** Each client in the snapshot gets an
|
||||||
muted/deafened state in avatar badges. Per-user volume UI, persistence,
|
independent volume slider persisted in the bridge layer. Avatar
|
||||||
and mixer wiring remain tracked as follow-up work.
|
badges show muted/deafened state. Volume adjustments take effect
|
||||||
|
immediately on the audio mix.
|
||||||
- **Non-self client info parity with Qint.** The Info tab now populates
|
- **Non-self client info parity with Qint.** The Info tab now populates
|
||||||
connection metadata (name, description, created, last connected,
|
connection metadata (name, description, created, last connected,
|
||||||
connections, transfer, ping deviation) for other clients via an
|
connections, transfer, ping deviation) for other clients via an
|
||||||
@@ -30,10 +29,9 @@ required NDK compiler and an authorized ADB target.
|
|||||||
- **Ping deviation in client profiles.** `ping_deviation_milliseconds`
|
- **Ping deviation in client profiles.** `ping_deviation_milliseconds`
|
||||||
propagated from protocol DTO through bridge API to Dart, with a
|
propagated from protocol DTO through bridge API to Dart, with a
|
||||||
conditional l10n row in the client info sheet (en + zh).
|
conditional l10n row in the client info sheet (en + zh).
|
||||||
- **Apple CoreML Silero VAD scaffolding/assets** for iOS / macOS when
|
- **Apple CoreML Silero VAD** as the preferred voice activity detector
|
||||||
the private `silero-coreml` SwiftPM package is available. Product
|
on iOS / macOS when the private `silero-coreml` SwiftPM submodule is
|
||||||
`VoiceActivity` remains reserved/disabled per DEC-030 until a later
|
available. WebRTC VAD remains the runtime fallback.
|
||||||
baseline enables and verifies it.
|
|
||||||
- **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
|
- **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
|
||||||
lookups and `ts3server://` URI handling.
|
lookups and `ts3server://` URI handling.
|
||||||
- **Per-ABI Android APK splitting.** `flutter build apk
|
- **Per-ABI Android APK splitting.** `flutter build apk
|
||||||
@@ -52,9 +50,8 @@ required NDK compiler and an authorized ADB target.
|
|||||||
- **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
|
- **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
|
||||||
serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
|
serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
|
||||||
audio startup.
|
audio startup.
|
||||||
- **Linux native audio path promoted** with ONNX Runtime VAD assets
|
- **Linux native audio path promoted** with ONNX Runtime bundled for
|
||||||
bundled for future `VoiceActivity` work. Desktop voice I/O works on
|
VAD. Desktop voice I/O works on PipeWire / PulseAudio.
|
||||||
PipeWire / PulseAudio; product `VoiceActivity` remains disabled.
|
|
||||||
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
|
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
|
||||||
startup permission flow, and system back-button integration.
|
startup permission flow, and system back-button integration.
|
||||||
- **`SnapshotChanged` event removed.** Replaced by the typed delta
|
- **`SnapshotChanged` event removed.** Replaced by the typed delta
|
||||||
@@ -62,8 +59,7 @@ required NDK compiler and an authorized ADB target.
|
|||||||
Flutter).
|
Flutter).
|
||||||
- **Prefetch crate renamed** from the PoC-era name to
|
- **Prefetch crate renamed** from the PoC-era name to
|
||||||
`chanora_prefetch`. All docs, specs, and code updated.
|
`chanora_prefetch`. All docs, specs, and code updated.
|
||||||
- **Flutter app version/build bumped to `0.3.0+100`.** Rust workspace
|
- **Build number bumped to 76.**
|
||||||
packages remain versioned separately at `0.2.0-beta.1`.
|
|
||||||
- **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
|
- **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Generated
+76
-74
@@ -70,15 +70,6 @@ dependencies = [
|
|||||||
"backtrace",
|
"backtrace",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "alloca"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alsa"
|
name = "alsa"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -428,21 +419,21 @@ name = "chanora_audio"
|
|||||||
version = "0.2.0-beta.1"
|
version = "0.2.0-beta.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"audiopus",
|
"audiopus",
|
||||||
"bytemuck",
|
|
||||||
"chanora_protocol",
|
"chanora_protocol",
|
||||||
"coreaudio-rs",
|
"coreaudio-rs",
|
||||||
"cpal",
|
"cpal",
|
||||||
"criterion",
|
"criterion",
|
||||||
"crossbeam",
|
"crossbeam",
|
||||||
|
"crossbeam-utils",
|
||||||
"dhat",
|
"dhat",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"jni 0.22.4",
|
"jni 0.21.1",
|
||||||
"ndarray",
|
"ndarray",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"oboe",
|
"oboe",
|
||||||
"ort",
|
"ort",
|
||||||
"rand 0.10.1",
|
"rand 0.8.6",
|
||||||
"rustfft",
|
"rustfft",
|
||||||
"sdl2",
|
"sdl2",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -453,7 +444,7 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tsclientlib",
|
"tsclientlib",
|
||||||
"webrtc-vad",
|
"webrtc-vad",
|
||||||
"windows",
|
"windows 0.54.0",
|
||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -532,7 +523,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chanora_resolver"
|
name = "chanora_resolver"
|
||||||
version = "0.2.0-beta.1"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"hickory-resolver",
|
"hickory-resolver",
|
||||||
@@ -726,15 +717,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpal"
|
name = "cpal"
|
||||||
version = "0.18.0"
|
version = "0.17.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9dd2b2151ebb4d5866c804d89fe28244bfb6b74481b9b4d406e4ec4d7f88ce5"
|
checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alsa",
|
"alsa",
|
||||||
"block2",
|
|
||||||
"coreaudio-rs",
|
"coreaudio-rs",
|
||||||
"dasp_sample",
|
"dasp_sample",
|
||||||
"jni 0.22.4",
|
"jni 0.21.1",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"mach2",
|
"mach2",
|
||||||
@@ -749,9 +739,10 @@ dependencies = [
|
|||||||
"objc2-core-audio-types",
|
"objc2-core-audio-types",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"windows",
|
"windows 0.62.2",
|
||||||
"windows-core",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -774,24 +765,25 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "criterion"
|
name = "criterion"
|
||||||
version = "0.8.2"
|
version = "0.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
|
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloca",
|
|
||||||
"anes",
|
"anes",
|
||||||
"cast",
|
"cast",
|
||||||
"ciborium",
|
"ciborium",
|
||||||
"clap",
|
"clap",
|
||||||
"criterion-plot",
|
"criterion-plot",
|
||||||
"itertools 0.13.0",
|
"is-terminal",
|
||||||
|
"itertools 0.10.5",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"once_cell",
|
||||||
"oorandom",
|
"oorandom",
|
||||||
"page_size",
|
|
||||||
"plotters",
|
"plotters",
|
||||||
"rayon",
|
"rayon",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tinytemplate",
|
"tinytemplate",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -799,12 +791,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "criterion-plot"
|
name = "criterion-plot"
|
||||||
version = "0.8.2"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
|
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cast",
|
"cast",
|
||||||
"itertools 0.13.0",
|
"itertools 0.10.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1993,7 +1985,7 @@ dependencies = [
|
|||||||
"socket2",
|
"socket2",
|
||||||
"widestring",
|
"widestring",
|
||||||
"windows-registry",
|
"windows-registry",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2007,10 +1999,21 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "is-terminal"
|
||||||
version = "0.13.0"
|
version = "0.4.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||||
|
dependencies = [
|
||||||
|
"hermit-abi",
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.10.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"either",
|
"either",
|
||||||
]
|
]
|
||||||
@@ -2235,9 +2238,12 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mach2"
|
name = "mach2"
|
||||||
version = "0.6.0"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
|
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
@@ -2534,7 +2540,6 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.12.1",
|
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
@@ -2747,16 +2752,6 @@ dependencies = [
|
|||||||
"sha2",
|
"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]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -3503,9 +3498,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sdl2"
|
name = "sdl2"
|
||||||
version = "0.38.0"
|
version = "0.37.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef"
|
checksum = "3b498da7d14d1ad6c839729bd4ad6fc11d90a57583605f3b4df2cd709a9cd380"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
@@ -3515,9 +3510,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sdl2-sys"
|
name = "sdl2-sys"
|
||||||
version = "0.38.0"
|
version = "0.37.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0"
|
checksum = "951deab27af08ed9c6068b7b0d05a93c91f0a8eb16b6b816a5e73452a43521d3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -4773,22 +4768,6 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
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]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -4799,10 +4778,14 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-x86_64-pc-windows-gnu"
|
name = "windows"
|
||||||
version = "0.4.0"
|
version = "0.54.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.54.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
@@ -4811,7 +4794,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-collections",
|
"windows-collections",
|
||||||
"windows-core",
|
"windows-core 0.62.2",
|
||||||
"windows-future",
|
"windows-future",
|
||||||
"windows-numerics",
|
"windows-numerics",
|
||||||
]
|
]
|
||||||
@@ -4822,7 +4805,17 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core",
|
"windows-core 0.62.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.54.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||||
|
dependencies = [
|
||||||
|
"windows-result 0.1.2",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4834,7 +4827,7 @@ dependencies = [
|
|||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
"windows-strings",
|
"windows-strings",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4844,7 +4837,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core",
|
"windows-core 0.62.2",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
"windows-threading",
|
"windows-threading",
|
||||||
]
|
]
|
||||||
@@ -4883,7 +4876,7 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core",
|
"windows-core 0.62.2",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4894,10 +4887,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
"windows-strings",
|
"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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|||||||
-14
@@ -82,17 +82,3 @@ tsproto-types = { git = "https://github.com/EdisonJwa/tsclientlib.git", branch =
|
|||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
cmake = { git = "https://github.com/pr2502/cmake-rs", rev = "bdad5edc569d82151922c5c6c4685b1563f12aa1" }
|
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
|
## Status
|
||||||
|
|
||||||
Chanora is currently a baseline-candidate Flutter + Rust workspace. It is not production-ready and is not approved for public or store release.
|
Chanora is currently in early planning and baseline-candidate design.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Current documentation baseline: v0.9.x document set
|
Current documentation baseline: v0.9.2
|
||||||
Current status: Baseline Candidate
|
Current status: Baseline Candidate
|
||||||
Implementation status: Not production-ready
|
Implementation status: Not production-ready
|
||||||
```
|
```
|
||||||
@@ -25,7 +25,7 @@ Implementation status: Not production-ready
|
|||||||
The current engineering focus is:
|
The current engineering focus is:
|
||||||
|
|
||||||
- defining the system and software architecture;
|
- defining the system and software architecture;
|
||||||
- hardening the Flutter + Rust application structure;
|
- preparing the Flutter + Rust application structure;
|
||||||
- validating TeamSpeak-compatible protocol integration through `tsclientlib`;
|
- validating TeamSpeak-compatible protocol integration through `tsclientlib`;
|
||||||
- defining cross-platform audio behavior;
|
- defining cross-platform audio behavior;
|
||||||
- preparing release, verification, security, privacy, and legal gates.
|
- preparing release, verification, security, privacy, and legal gates.
|
||||||
@@ -49,7 +49,7 @@ Current platform policy:
|
|||||||
| iOS / iPadOS runtime target | iOS 16+ while Apple CoreML Silero VAD is linked |
|
| iOS / iPadOS runtime target | iOS 16+ while Apple CoreML Silero VAD is linked |
|
||||||
| macOS runtime target | macOS 13+ while Apple CoreML Silero VAD is linked |
|
| macOS runtime target | macOS 13+ while Apple CoreML Silero VAD is linked |
|
||||||
| App Store Connect upload gate | Xcode 26+ with iOS 26 / iPadOS 26 SDK+ for upload on or after 2026-04-28 |
|
| App Store Connect upload gate | Xcode 26+ with iOS 26 / iPadOS 26 SDK+ for upload on or after 2026-04-28 |
|
||||||
| Android runtime target | Android API 28+ per DEC-004, SysRS-288, SRS-187, and Gradle `minSdk = 28` |
|
| Android runtime target | Android API 24+ unless Flutter, plugin, audio, or product constraints require raising it |
|
||||||
| Google Play target API | Target the Google Play-required API level on upload date |
|
| Google Play target API | Target the Google Play-required API level on upload date |
|
||||||
|
|
||||||
The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
|
The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
|
||||||
@@ -230,7 +230,7 @@ docs/
|
|||||||
aspice-swe2-swe3-integration-note.md
|
aspice-swe2-swe3-integration-note.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Implementation source folders are present in this workspace. The current high-level structure is:
|
Implementation source folders may be added later. A likely structure is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
apps/
|
apps/
|
||||||
@@ -248,7 +248,7 @@ crates/
|
|||||||
chanora_bridge/
|
chanora_bridge/
|
||||||
```
|
```
|
||||||
|
|
||||||
The exact implementation layout may continue to evolve as maintainability reviews split or merge Modules, but the repository scaffold exists.
|
The exact implementation layout should be finalized when the repository scaffold is created.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -395,7 +395,9 @@ docs/governance/git-commit-message-convention.md
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Common local commands include:
|
Implementation commands will be added after the repository scaffold is finalized.
|
||||||
|
|
||||||
|
Expected future commands may include:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter pub get
|
flutter pub get
|
||||||
@@ -405,7 +407,7 @@ cargo clippy
|
|||||||
cargo fmt
|
cargo fmt
|
||||||
```
|
```
|
||||||
|
|
||||||
Android runtime success also requires an available Android NDK toolchain and an authorized device or emulator for build/install/smoke verification.
|
Do not treat these as authoritative until the actual Flutter/Rust workspace has been created.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -63,10 +63,8 @@ android {
|
|||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlinOptions {
|
||||||
compilerOptions {
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
|||||||
@@ -1,6 +1,2 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
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 {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.13.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>method</key>
|
|
||||||
<string>ad-hoc</string>
|
|
||||||
<key>destination</key>
|
|
||||||
<string>export</string>
|
|
||||||
<key>signingStyle</key>
|
|
||||||
<string>manual</string>
|
|
||||||
<key>stripSwiftSymbols</key>
|
|
||||||
<true/>
|
|
||||||
<key>uploadBitcode</key>
|
|
||||||
<false/>
|
|
||||||
<key>uploadSymbols</key>
|
|
||||||
<true/>
|
|
||||||
<key>teamID</key>
|
|
||||||
<string>ZNVDEVDRX3</string>
|
|
||||||
<key>provisioningProfiles</key>
|
|
||||||
<dict>
|
|
||||||
<key>app.teamspeak.chanora</key>
|
|
||||||
<string>Chanora_Ad_Hoc</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,6 +1,2 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
|
||||||
// Mirror Release.xcconfig (see explanation there).
|
|
||||||
OTHER_LDFLAGS = $(inherited) -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
|
|
||||||
STRIP_STYLE = non-global
|
|
||||||
|
|||||||
@@ -1,27 +1,2 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.release.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#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
|
flutter_ios_podfile_setup
|
||||||
|
|
||||||
target 'Chanora' do
|
target 'Runner' do
|
||||||
use_frameworks!
|
use_frameworks!
|
||||||
|
|
||||||
# Chanora Rust bridge as a vendored framework. The podspec runs
|
# Chanora Rust bridge as a vendored framework. The podspec runs
|
||||||
|
|||||||
@@ -1,33 +1,70 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- audio_session (0.0.1):
|
||||||
|
- Flutter
|
||||||
- chanora_bridge (1.0.0)
|
- chanora_bridge (1.0.0)
|
||||||
|
- connectivity_plus (0.0.1):
|
||||||
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_foreground_task (0.0.1):
|
- flutter_foreground_task (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- haptic_kit (1.0.0):
|
- haptic_kit (1.0.0):
|
||||||
- Flutter
|
- 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:
|
DEPENDENCIES:
|
||||||
|
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||||
- chanora_bridge (from `.`)
|
- chanora_bridge (from `.`)
|
||||||
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
|
- flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
|
||||||
- haptic_kit (from `.symlinks/plugins/haptic_kit/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:
|
EXTERNAL SOURCES:
|
||||||
|
audio_session:
|
||||||
|
:path: ".symlinks/plugins/audio_session/ios"
|
||||||
chanora_bridge:
|
chanora_bridge:
|
||||||
:path: "."
|
:path: "."
|
||||||
|
connectivity_plus:
|
||||||
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_foreground_task:
|
flutter_foreground_task:
|
||||||
:path: ".symlinks/plugins/flutter_foreground_task/ios"
|
:path: ".symlinks/plugins/flutter_foreground_task/ios"
|
||||||
haptic_kit:
|
haptic_kit:
|
||||||
:path: ".symlinks/plugins/haptic_kit/ios"
|
: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:
|
SPEC CHECKSUMS:
|
||||||
chanora_bridge: 26252acdf9ca660ce9c132ad25cd5ad5af467b16
|
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||||
|
chanora_bridge: 2ed7c2ba427fab135dd9eab66c507b09cfee113a
|
||||||
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
||||||
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
||||||
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
|
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||||
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
|
|
||||||
PODFILE CHECKSUM: 85b93b53f958f1ff700a147e9da4374c8b1c6970
|
PODFILE CHECKSUM: e2123068539aeb66d53dc1612b383d13f489ede2
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -8,18 +8,17 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
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 */; };
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
3EF79A791760D95CE0F41CFF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */; };
|
3EF79A791760D95CE0F41CFF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.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 */; };
|
8C5000012DD0000000000001 /* SileroCoreMLBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */; };
|
||||||
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */ = {isa = PBXBuildFile; productRef = 8C5000032DD0000000000001 /* SileroCoreML */; };
|
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */ = {isa = PBXBuildFile; productRef = 8C5000032DD0000000000001 /* SileroCoreML */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
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 */; };
|
FD3C80659716BF7A0C95C7AF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -48,10 +47,10 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* 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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
@@ -60,23 +59,19 @@
|
|||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SileroCoreMLBridge.swift; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
97C146EE1CF9000F007C117D /* Chanora.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Chanora.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -84,9 +79,8 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
|
||||||
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */,
|
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */,
|
||||||
C8BACE02E6EE5F840EE3F174 /* Pods_Chanora.framework in Frameworks */,
|
1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -112,8 +106,8 @@
|
|||||||
4351F25046559EFA4C03047A /* Frameworks */ = {
|
4351F25046559EFA4C03047A /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
0B4754099284EEDCD859A973 /* Pods_Runner.framework */,
|
||||||
63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */,
|
63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */,
|
||||||
DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */,
|
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -127,9 +121,6 @@
|
|||||||
73171B86DD76CC3E5A58E160 /* Pods-RunnerTests.debug.xcconfig */,
|
73171B86DD76CC3E5A58E160 /* Pods-RunnerTests.debug.xcconfig */,
|
||||||
E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */,
|
E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */,
|
||||||
076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */,
|
076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */,
|
||||||
EAE6402BFC041304D1D0896D /* Pods-Chanora.debug.xcconfig */,
|
|
||||||
2EA1142FBFD36E2ED564A5AA /* Pods-Chanora.release.xcconfig */,
|
|
||||||
A23C02505CD7E5092CA7958C /* Pods-Chanora.profile.xcconfig */,
|
|
||||||
);
|
);
|
||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -137,7 +128,6 @@
|
|||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
@@ -161,7 +151,7 @@
|
|||||||
97C146EF1CF9000F007C117D /* Products */ = {
|
97C146EF1CF9000F007C117D /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
97C146EE1CF9000F007C117D /* Chanora.app */,
|
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
@@ -207,15 +197,14 @@
|
|||||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.unit-test";
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
};
|
};
|
||||||
97C146ED1CF9000F007C117D /* Chanora */ = {
|
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Chanora" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */,
|
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */,
|
||||||
9740EEB61CF901F6004384FC /* Run Script */,
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
97C146EA1CF9000F007C117D /* Sources */,
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
CA110001000000000000A100 /* Verify Silero Exports */,
|
|
||||||
97C146EC1CF9000F007C117D /* Resources */,
|
97C146EC1CF9000F007C117D /* Resources */,
|
||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
@@ -225,13 +214,12 @@
|
|||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
name = Chanora;
|
name = Runner;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
|
||||||
8C5000032DD0000000000001 /* SileroCoreML */,
|
8C5000032DD0000000000001 /* SileroCoreML */,
|
||||||
);
|
);
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
productReference = 97C146EE1CF9000F007C117D /* Chanora.app */;
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
@@ -264,14 +252,13 @@
|
|||||||
);
|
);
|
||||||
mainGroup = 97C146E51CF9000F007C117D;
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
|
||||||
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
|
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
97C146ED1CF9000F007C117D /* Chanora */,
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -344,15 +331,15 @@
|
|||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */ = {
|
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */ = {
|
||||||
@@ -370,7 +357,7 @@
|
|||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
);
|
);
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
"$(DERIVED_FILE_DIR)/Pods-Chanora-checkManifestLockResult.txt",
|
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
@@ -392,21 +379,6 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
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 */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -434,7 +406,7 @@
|
|||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = 97C146ED1CF9000F007C117D /* Chanora */;
|
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
@@ -518,23 +490,18 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CODE_SIGN_STYLE = Manual;
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
CURRENT_PROJECT_VERSION = 101;
|
DEVELOPMENT_TEAM = 349G7M4TQQ;
|
||||||
DEVELOPMENT_TEAM = "";
|
|
||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
|
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
|
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "Chanora_iOS_Ad Hoc";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Chanora_iOS_Ad Hoc";
|
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
@@ -555,7 +522,7 @@
|
|||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -571,7 +538,7 @@
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -587,7 +554,7 @@
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
};
|
};
|
||||||
name = Profile;
|
name = Profile;
|
||||||
};
|
};
|
||||||
@@ -709,23 +676,18 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CODE_SIGN_STYLE = Manual;
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
CURRENT_PROJECT_VERSION = 101;
|
DEVELOPMENT_TEAM = 349G7M4TQQ;
|
||||||
DEVELOPMENT_TEAM = "";
|
|
||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
|
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
|
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = Chanora_ios_Development;
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = Chanora_ios_Development;
|
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -740,23 +702,18 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CODE_SIGN_STYLE = Manual;
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
CURRENT_PROJECT_VERSION = 101;
|
DEVELOPMENT_TEAM = 349G7M4TQQ;
|
||||||
DEVELOPMENT_TEAM = "";
|
|
||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
|
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
|
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "Chanora_App Store";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Chanora_App Store";
|
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
@@ -786,7 +743,7 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Chanora" */ = {
|
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
97C147061CF9000F007C117D /* Debug */,
|
97C147061CF9000F007C117D /* Debug */,
|
||||||
@@ -799,21 +756,13 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
|
||||||
isa = XCLocalSwiftPackageReference;
|
|
||||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
|
||||||
};
|
|
||||||
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */ = {
|
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = "../../../silero-coreml";
|
relativePath = ../../../silero-coreml;
|
||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = FlutterGeneratedPluginSwiftPackage;
|
|
||||||
};
|
|
||||||
8C5000032DD0000000000001 /* SileroCoreML */ = {
|
8C5000032DD0000000000001 /* SileroCoreML */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
|
package = 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
|
||||||
|
|||||||
@@ -1,28 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1510"
|
LastUpgradeVersion = "1510"
|
||||||
version = "1.7">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "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>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -33,8 +15,8 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
BuildableName = "Chanora.app"
|
BuildableName = "Runner.app"
|
||||||
BlueprintName = "Chanora"
|
BlueprintName = "Runner"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
@@ -50,8 +32,8 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
BuildableName = "Chanora.app"
|
BuildableName = "Runner.app"
|
||||||
BlueprintName = "Chanora"
|
BlueprintName = "Runner"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</MacroExpansion>
|
</MacroExpansion>
|
||||||
@@ -86,8 +68,8 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
BuildableName = "Chanora.app"
|
BuildableName = "Runner.app"
|
||||||
BlueprintName = "Chanora"
|
BlueprintName = "Runner"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
@@ -103,8 +85,8 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
BuildableName = "Chanora.app"
|
BuildableName = "Runner.app"
|
||||||
BlueprintName = "Chanora"
|
BlueprintName = "Runner"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
|||||||
@@ -6,53 +6,95 @@ import AVFoundation
|
|||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
||||||
private var iosPlatformChannel: FlutterMethodChannel?
|
private var iosPlatformChannel: FlutterMethodChannel?
|
||||||
private var iosAudioSessionChannel: FlutterMethodChannel?
|
|
||||||
|
|
||||||
/// Tracks whether a voice channel is currently active.
|
|
||||||
///
|
|
||||||
/// The AVAudioSession is intentionally not configured for VoIP at
|
|
||||||
/// app launch — that would interrupt other apps' audio (Spotify,
|
|
||||||
/// Apple Music, podcasts) the moment the user opens Chanora, even
|
|
||||||
/// when they're just reading chat. Production VoIP apps (Telegram
|
|
||||||
/// group calls, Signal, Discord, Element) only switch the session
|
|
||||||
/// to `.playAndRecord` + `.voiceChat` when the user actually joins
|
|
||||||
/// a voice channel. See `docs/architecture/sad.md` and the
|
|
||||||
/// `chanora/ios_audio_session` MethodChannel contract.
|
|
||||||
///
|
|
||||||
/// This flag gates lifecycle handlers (interruption-ended,
|
|
||||||
/// media-services-reset) so we only rebuild the VoIP session if a
|
|
||||||
/// call is actually in progress. When false, those handlers leave
|
|
||||||
/// the session in the inactive `.ambient` baseline.
|
|
||||||
private var voiceSessionActive: Bool = false
|
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
DispatchQueue.global(qos: .utility).async {
|
// Configure the iOS AVAudioSession **category + mode** at
|
||||||
ChanoraSileroSelfTest.run()
|
// app-launch time, but DEFER setActive(true) until the scene
|
||||||
|
// is foregrounded. Calling setActive in didFinishLaunching is
|
||||||
|
// racy on iOS 17+ devices: if the user launches the app from a
|
||||||
|
// cold state, the UIApplication isn't yet `.active` and
|
||||||
|
// setActive returns `AVAudioSessionErrorCodeCannotStartPlaying`
|
||||||
|
// (561017449) — the iOS audio policy server refuses to grant
|
||||||
|
// the audio session because the app is not yet considered the
|
||||||
|
// foreground priority owner. Symptom in production builds:
|
||||||
|
// 'AVAudioSession setup failed: Error 561017449 "Session
|
||||||
|
// activation failed"' in NSLog, after which the audio engine
|
||||||
|
// is unusable until the user backgrounds + foregrounds the
|
||||||
|
// app.
|
||||||
|
//
|
||||||
|
// The category itself can be set whenever; only the active
|
||||||
|
// state needs to be deferred. We listen for
|
||||||
|
// didBecomeActiveNotification and activate then. Most
|
||||||
|
// production iOS voice apps (Discord, Zoom, FaceTime) follow
|
||||||
|
// this same shape.
|
||||||
|
do {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .voiceChat,
|
||||||
|
// Mode rationale (May 2026, .voiceChat reinstated):
|
||||||
|
//
|
||||||
|
// We previously used .default mode after discovering that
|
||||||
|
// .voiceChat routed output through iOS's in-call audio
|
||||||
|
// channel, which made speaker output barely audible. That
|
||||||
|
// bug was caused by cpal's RemoteIO unit binding to a stale
|
||||||
|
// physical transducer — after migrating to coreaudio-rs +
|
||||||
|
// kAudioUnitSubType_VoiceProcessingIO (see
|
||||||
|
// crates/chanora_audio/src/ios_voice_unit.rs) the route
|
||||||
|
// binding is correct under either mode because VPIO re-binds
|
||||||
|
// on overrideOutputAudioPort.
|
||||||
|
//
|
||||||
|
// .voiceChat advantages over .default:
|
||||||
|
// * Tells iOS this is a VoIP session — other apps' audio
|
||||||
|
// is properly ducked/paused instead of competing.
|
||||||
|
// * Enables correct Bluetooth HFP negotiation without
|
||||||
|
// manual workarounds.
|
||||||
|
// * iOS treats the audio session as a "call" for priority
|
||||||
|
// purposes (won't be interrupted by notification sounds).
|
||||||
|
// * System-level CallKit integration (lock-screen controls).
|
||||||
|
//
|
||||||
|
// .defaultToSpeaker ensures output goes to the main speaker
|
||||||
|
// (not the earpiece) by default when no headphones are
|
||||||
|
// connected, compensating for the in-call channel's tendency
|
||||||
|
// to route to the earpiece.
|
||||||
|
//
|
||||||
|
// References:
|
||||||
|
// * https://github.com/twilio/video-quickstart-ios/issues/522
|
||||||
|
// * https://stackoverflow.com/questions/79834998 (Daily.co)
|
||||||
|
//
|
||||||
|
// Options:
|
||||||
|
// .defaultToSpeaker : route output to the main speaker
|
||||||
|
// (not the earpiece) by default
|
||||||
|
// when no headphones are connected.
|
||||||
|
// .allowBluetoothHFP : permit Bluetooth Hands-Free
|
||||||
|
// Profile headsets as both input
|
||||||
|
// and output.
|
||||||
|
// .allowBluetoothA2DP : permit higher-quality A2DP
|
||||||
|
// output-only Bluetooth devices.
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||||
|
)
|
||||||
|
// Match VPIO / Opus frame cadence to reduce callback pressure.
|
||||||
|
try session.setPreferredIOBufferDuration(0.02)
|
||||||
|
try session.setPreferredSampleRate(48000.0)
|
||||||
|
logAudioSessionState(context: "setCategory")
|
||||||
|
} catch {
|
||||||
|
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// AVAudioSession lifecycle policy (DEC-2026-06-08, supersedes
|
// Activate the session once the app is actually foreground. The
|
||||||
// the launch-time .playAndRecord setup):
|
// notification fires immediately after the cold-launch settles,
|
||||||
//
|
// and again on every resume-from-background — both safe
|
||||||
// At launch we set the category to .ambient and leave the
|
// moments to call setActive(true). Repeated activation while
|
||||||
// session INACTIVE — matching the Telegram / Signal / Discord /
|
// already-active is a no-op per the docs.
|
||||||
// Element / Jitsi pattern and Apple's guidance that "a VoIP
|
NotificationCenter.default.addObserver(
|
||||||
// app's audio session should not be active" while idle.
|
self,
|
||||||
// Configuring .playAndRecord + .voiceChat at launch stops other
|
selector: #selector(activateAudioSession),
|
||||||
// apps' music (Spotify, Apple Music, podcasts) the moment the
|
name: UIApplication.didBecomeActiveNotification,
|
||||||
// user opens Chanora, even when they are just reading text chat.
|
object: nil
|
||||||
//
|
)
|
||||||
// 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(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
@@ -78,60 +120,37 @@ import AVFoundation
|
|||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Activate the VoIP audio session. Called from Dart via the
|
/// Called by `didBecomeActiveNotification` (cold-launch settle +
|
||||||
/// `chanora/ios_audio_session` channel when a voice channel join
|
/// every resume-from-background). Activates the AVAudioSession.
|
||||||
/// reaches the `BridgeEvent::AudioStarted` stage. Configures
|
/// Repeated activation is a no-op when the session is already
|
||||||
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
|
/// active so this is safe to call on every foreground.
|
||||||
/// (Spotify, podcasts) can keep playing alongside the voice
|
@objc private func activateAudioSession() {
|
||||||
/// channel — matching the Telegram group-call UX. Idempotent:
|
|
||||||
/// repeated calls while already active are a no-op.
|
|
||||||
private func activateVoiceSession() {
|
|
||||||
do {
|
do {
|
||||||
let session = AVAudioSession.sharedInstance()
|
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
||||||
try session.setCategory(
|
NSLog("chanora_flutter: AVAudioSession activated on foreground")
|
||||||
.playAndRecord,
|
// Read back the ACTUAL session state. preferredSampleRate /
|
||||||
mode: .voiceChat,
|
// preferredIOBufferDuration are hints; iOS may pick something
|
||||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
// else depending on hardware + currently-engaged effects.
|
||||||
)
|
// Without these we can't tell whether VPIO is running at
|
||||||
try session.setPreferredIOBufferDuration(0.02)
|
// 48 kHz mono (what our render callback assumes) or at e.g.
|
||||||
try session.setPreferredSampleRate(48000.0)
|
// 44.1 kHz (which would explain the user's broken playback
|
||||||
try session.setActive(true, options: [])
|
// \u2014 our render callback would be writing samples at the
|
||||||
voiceSessionActive = true
|
// wrong rate, causing pitch + timing artifacts).
|
||||||
logAudioSessionState(context: "activateVoiceSession")
|
logAudioSessionState(context: "setActive")
|
||||||
let ins = session.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
let s = AVAudioSession.sharedInstance()
|
||||||
|
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||||
NSLog(
|
NSLog(
|
||||||
"chanora_flutter: voice session active: " +
|
"chanora_flutter: AVAudioSession actual: " +
|
||||||
"sampleRate=\(session.sampleRate) " +
|
"sampleRate=\(s.sampleRate) " +
|
||||||
"ioBufferDuration=\(String(format: "%.4f", session.ioBufferDuration)) " +
|
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
|
||||||
"inputs=[\(ins)] outputVolume=\(session.outputVolume)"
|
"inputs=[\(ins)] " +
|
||||||
|
"outputVolume=\(s.outputVolume)"
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("chanora_flutter: activateVoiceSession failed: \(error)")
|
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deactivate the VoIP audio session and return to the idle
|
|
||||||
/// .ambient baseline. Called from Dart on `BridgeEvent::AudioStopped`
|
|
||||||
/// (intentional leave, disconnect, or connection lost).
|
|
||||||
/// `.notifyOthersOnDeactivation` lets other audio apps know they
|
|
||||||
/// can resume — best-effort: Apple Music / Podcasts resume
|
|
||||||
/// reliably, Spotify is not guaranteed.
|
|
||||||
private func deactivateVoiceSession() {
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
do {
|
|
||||||
try session.setActive(false, options: [.notifyOthersOnDeactivation])
|
|
||||||
} catch {
|
|
||||||
NSLog("chanora_flutter: deactivateVoiceSession setActive(false) failed: \(error)")
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try session.setCategory(.ambient, mode: .default)
|
|
||||||
} catch {
|
|
||||||
NSLog("chanora_flutter: deactivateVoiceSession setCategory(.ambient) failed: \(error)")
|
|
||||||
}
|
|
||||||
voiceSessionActive = false
|
|
||||||
logAudioSessionState(context: "deactivateVoiceSession")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads back the actual AVAudioSession state and logs it for
|
/// Reads back the actual AVAudioSession state and logs it for
|
||||||
/// SDD-098 compliance. Called after both setCategory and setActive
|
/// SDD-098 compliance. Called after both setCategory and setActive
|
||||||
/// to verify that the session accepted the requested configuration.
|
/// to verify that the session accepted the requested configuration.
|
||||||
@@ -196,30 +215,25 @@ import AVFoundation
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
||||||
NSLog("chanora_flutter: media services reset voiceActive=\(voiceSessionActive)")
|
NSLog("chanora_flutter: media services reset")
|
||||||
if voiceSessionActive {
|
do {
|
||||||
do {
|
let session = AVAudioSession.sharedInstance()
|
||||||
let session = AVAudioSession.sharedInstance()
|
try session.setCategory(
|
||||||
try session.setCategory(
|
.playAndRecord,
|
||||||
.playAndRecord,
|
mode: .voiceChat,
|
||||||
mode: .voiceChat,
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
)
|
||||||
)
|
try session.setPreferredIOBufferDuration(0.02)
|
||||||
try session.setPreferredIOBufferDuration(0.02)
|
try session.setPreferredSampleRate(48000.0)
|
||||||
try session.setPreferredSampleRate(48000.0)
|
try session.setActive(true, options: [])
|
||||||
try session.setActive(true, options: [])
|
logAudioSessionState(context: "mediaServicesWereReset")
|
||||||
logAudioSessionState(context: "mediaServicesWereReset-voip")
|
} catch {
|
||||||
} catch {
|
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
||||||
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
do {
|
|
||||||
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
|
||||||
logAudioSessionState(context: "mediaServicesWereReset-ambient")
|
|
||||||
} catch {
|
|
||||||
NSLog("chanora_flutter: AVAudioSession media-services reset ambient restore failed: \(error)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// P1: After rebuilding the session, send the current route class to
|
||||||
|
// Rust so it can recompute the processing policy and reset the
|
||||||
|
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
|
||||||
|
// which calls ios_restart_voice_unit.
|
||||||
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
|
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
|
||||||
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
||||||
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
||||||
@@ -251,26 +265,6 @@ import AVFoundation
|
|||||||
name: "chanora/ios_platform",
|
name: "chanora/ios_platform",
|
||||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||||
)
|
)
|
||||||
iosAudioSessionChannel = FlutterMethodChannel(
|
|
||||||
name: "chanora/ios_audio_session",
|
|
||||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
|
||||||
)
|
|
||||||
iosAudioSessionChannel?.setMethodCallHandler { [weak self] call, result in
|
|
||||||
guard let self = self else {
|
|
||||||
result(FlutterError(code: "delegate_gone", message: "AppDelegate deallocated", details: nil))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch call.method {
|
|
||||||
case "activateVoiceSession":
|
|
||||||
self.activateVoiceSession()
|
|
||||||
result(nil)
|
|
||||||
case "deactivateVoiceSession":
|
|
||||||
self.deactivateVoiceSession()
|
|
||||||
result(nil)
|
|
||||||
default:
|
|
||||||
result(FlutterMethodNotImplemented)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
iosPlatformChannel?.setMethodCallHandler { call, result in
|
iosPlatformChannel?.setMethodCallHandler { call, result in
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "getMicrophonePermissionState":
|
case "getMicrophonePermissionState":
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<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>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -24,16 +27,10 @@
|
|||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
|
||||||
<true/>
|
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
|
||||||
<string>Chanora needs local network access to connect to your voice servers.</string>
|
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Chanora needs microphone access so you can talk on your voice server.</string>
|
<string>Chanora needs microphone access so you can talk on your TeamSpeak-compatible voice server.</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
@@ -61,8 +58,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIFileSharingEnabled</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<true/>
|
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
@@ -80,5 +77,9 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UIFileSharingEnabled</key>
|
||||||
|
<true/>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import CoreML
|
import CoreML
|
||||||
import Darwin
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SileroCoreML
|
import SileroCoreML
|
||||||
|
|
||||||
@@ -97,136 +96,3 @@ public func chanoraSileroVadFreeString(_ string: UnsafeMutablePointer<CChar>?) {
|
|||||||
guard let string else { return }
|
guard let string else { return }
|
||||||
free(string)
|
free(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public final class ChanoraSileroSelfTest: NSObject {
|
|
||||||
// Validates the same code path the Rust framework uses: dlsym(RTLD_DEFAULT) for all
|
|
||||||
// six @_cdecl symbols, then exercises create -> reset -> process -> destroy. Catches
|
|
||||||
// the dead-strip / linker-export class of bug that broke TestFlight; calling the Swift
|
|
||||||
// functions directly would mask it because direct calls bypass the dynamic symbol table.
|
|
||||||
@objc public static func run() {
|
|
||||||
let started = DispatchTime.now()
|
|
||||||
|
|
||||||
// Static linker references: keep the Swift compiler / linker from
|
|
||||||
// dead-stripping the @_cdecl symbols under Whole-Module-Optimization
|
|
||||||
// + LTO in Archive builds. dlsym(RTLD_DEFAULT) below does NOT count
|
|
||||||
// as a static reference for the dead-stripper — these `_ = ` lines
|
|
||||||
// do. Without them, TestFlight builds shipped without the symbols
|
|
||||||
// even though Debug builds (no LTO) worked.
|
|
||||||
//
|
|
||||||
// The `withoutActuallyEscaping` dance prevents the optimizer from
|
|
||||||
// proving the references are unused: assigning the function value
|
|
||||||
// to a `@convention(c)` typealias forces address-taken semantics.
|
|
||||||
_ = unsafeBitCast(
|
|
||||||
chanoraSileroVadCreate as @convention(c) () -> UnsafeMutableRawPointer?,
|
|
||||||
to: UnsafeRawPointer.self,
|
|
||||||
)
|
|
||||||
_ = unsafeBitCast(
|
|
||||||
chanoraSileroVadDestroy as @convention(c) (UnsafeMutableRawPointer?) -> Void,
|
|
||||||
to: UnsafeRawPointer.self,
|
|
||||||
)
|
|
||||||
_ = unsafeBitCast(
|
|
||||||
chanoraSileroVadReset as @convention(c) (UnsafeMutableRawPointer?) -> Int32,
|
|
||||||
to: UnsafeRawPointer.self,
|
|
||||||
)
|
|
||||||
_ = unsafeBitCast(
|
|
||||||
chanoraSileroVadProcess
|
|
||||||
as @convention(c) (
|
|
||||||
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int,
|
|
||||||
UnsafeMutablePointer<Float>?
|
|
||||||
) -> Int32,
|
|
||||||
to: UnsafeRawPointer.self,
|
|
||||||
)
|
|
||||||
_ = unsafeBitCast(
|
|
||||||
chanoraSileroVadLastError as @convention(c) () -> UnsafeMutablePointer<CChar>?,
|
|
||||||
to: UnsafeRawPointer.self,
|
|
||||||
)
|
|
||||||
_ = unsafeBitCast(
|
|
||||||
chanoraSileroVadFreeString as @convention(c) (UnsafeMutablePointer<CChar>?) -> Void,
|
|
||||||
to: UnsafeRawPointer.self,
|
|
||||||
)
|
|
||||||
|
|
||||||
typealias CreateFn = @convention(c) () -> UnsafeMutableRawPointer?
|
|
||||||
typealias DestroyFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
|
|
||||||
typealias ResetFn = @convention(c) (UnsafeMutableRawPointer?) -> Int32
|
|
||||||
typealias ProcessFn = @convention(c) (
|
|
||||||
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int, UnsafeMutablePointer<Float>?
|
|
||||||
) -> Int32
|
|
||||||
typealias LastErrorFn = @convention(c) () -> UnsafeMutablePointer<CChar>?
|
|
||||||
typealias FreeStringFn = @convention(c) (UnsafeMutablePointer<CChar>?) -> Void
|
|
||||||
|
|
||||||
func resolve<T>(_ name: String, as type: T.Type) -> T? {
|
|
||||||
guard let raw = dlsym(UnsafeMutableRawPointer(bitPattern: -2), name) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return unsafeBitCast(raw, to: type)
|
|
||||||
}
|
|
||||||
|
|
||||||
let names = [
|
|
||||||
"chanora_silero_vad_create",
|
|
||||||
"chanora_silero_vad_destroy",
|
|
||||||
"chanora_silero_vad_reset",
|
|
||||||
"chanora_silero_vad_process",
|
|
||||||
"chanora_silero_vad_last_error",
|
|
||||||
"chanora_silero_vad_free_string",
|
|
||||||
]
|
|
||||||
let missing = names.filter { dlsym(UnsafeMutableRawPointer(bitPattern: -2), $0) == nil }
|
|
||||||
if !missing.isEmpty {
|
|
||||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED dlsym missing=\(missing.joined(separator: ","))")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard
|
|
||||||
let create = resolve("chanora_silero_vad_create", as: CreateFn.self),
|
|
||||||
let destroy = resolve("chanora_silero_vad_destroy", as: DestroyFn.self),
|
|
||||||
let reset = resolve("chanora_silero_vad_reset", as: ResetFn.self),
|
|
||||||
let process = resolve("chanora_silero_vad_process", as: ProcessFn.self),
|
|
||||||
let lastError = resolve("chanora_silero_vad_last_error", as: LastErrorFn.self),
|
|
||||||
let freeString = resolve("chanora_silero_vad_free_string", as: FreeStringFn.self)
|
|
||||||
else {
|
|
||||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED unsafeBitCast resolution")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func readError() -> String {
|
|
||||||
guard let ptr = lastError() else { return "unknown" }
|
|
||||||
let msg = String(cString: ptr)
|
|
||||||
freeString(ptr)
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let handle = create() else {
|
|
||||||
let elapsedMs = elapsedMs(since: started)
|
|
||||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED at create err=\(readError()) elapsed_ms=\(elapsedMs)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let resetRc = reset(handle)
|
|
||||||
if resetRc != 0 {
|
|
||||||
destroy(handle)
|
|
||||||
let elapsedMs = elapsedMs(since: started)
|
|
||||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED at reset rc=\(resetRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let chunkSize = SileroVADRunner.chunkSize
|
|
||||||
var probability: Float = 0
|
|
||||||
let samples = [Float](repeating: 0, count: chunkSize)
|
|
||||||
let processRc = samples.withUnsafeBufferPointer { buf -> Int32 in
|
|
||||||
process(handle, buf.baseAddress, chunkSize, &probability)
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy(handle)
|
|
||||||
|
|
||||||
let elapsedMs = elapsedMs(since: started)
|
|
||||||
if processRc == 0 {
|
|
||||||
NSLog("chanora_flutter: SileroCoreML self-test OK probability=\(probability) elapsed_ms=\(elapsedMs)")
|
|
||||||
} else {
|
|
||||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED at process rc=\(processRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func elapsedMs(since start: DispatchTime) -> String {
|
|
||||||
let ns = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds
|
|
||||||
return String(format: "%.1f", Double(ns) / 1_000_000.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -85,13 +85,6 @@ Pod::Spec.new do |s|
|
|||||||
}
|
}
|
||||||
CARGO_BIN="$(find_cargo)"
|
CARGO_BIN="$(find_cargo)"
|
||||||
RUSTC_BIN="$(find_rustc)"
|
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"
|
echo "[chanora_bridge.podspec] cargo build aarch64-apple-ios"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
HOME="$USER_HOME" \\
|
HOME="$USER_HOME" \\
|
||||||
@@ -102,9 +95,6 @@ Pod::Spec.new do |s|
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
|
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
|
||||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||||
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
|
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
|
"$CARGO_BIN" build --release --target aarch64-apple-ios -p chanora_bridge
|
||||||
|
|
||||||
if [ ! -f "$BRIDGE" ]; then
|
if [ ! -f "$BRIDGE" ]; then
|
||||||
@@ -139,21 +129,7 @@ PLIST
|
|||||||
|
|
||||||
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
||||||
"$FW/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
|
SCRIPT
|
||||||
|
|
||||||
# Pod CocoaPods picks this up; the framework gets embedded into
|
# Pod CocoaPods picks this up; the framework gets embedded into
|
||||||
@@ -209,13 +185,6 @@ PLIST
|
|||||||
}
|
}
|
||||||
CARGO_BIN="$(find_cargo)"
|
CARGO_BIN="$(find_cargo)"
|
||||||
RUSTC_BIN="$(find_rustc)"
|
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
|
if [ "${PLATFORM_NAME:-iphoneos}" = "iphonesimulator" ]; then
|
||||||
RUST_TARGET="aarch64-apple-ios-sim"
|
RUST_TARGET="aarch64-apple-ios-sim"
|
||||||
SUPPORTED_PLATFORM="iPhoneSimulator"
|
SUPPORTED_PLATFORM="iPhoneSimulator"
|
||||||
@@ -234,9 +203,6 @@ PLIST
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
|
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
|
||||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||||
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
|
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
|
"$CARGO_BIN" build --release --target "$RUST_TARGET" -p chanora_bridge
|
||||||
|
|
||||||
cd "$REPO_ROOT/apps/chanora_flutter/ios"
|
cd "$REPO_ROOT/apps/chanora_flutter/ios"
|
||||||
@@ -244,19 +210,15 @@ PLIST
|
|||||||
|
|
||||||
# Skip the wrap step if the framework's binary is already
|
# Skip the wrap step if the framework's binary is already
|
||||||
# up-to-date with the cargo output (fast no-op on incremental
|
# up-to-date with the cargo output (fast no-op on incremental
|
||||||
# builds where Rust didn't change). We still publish the dSYM
|
# builds where Rust didn't change).
|
||||||
# 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
|
if [ -f "$FW/chanora_bridge" ] && [ "$FW/chanora_bridge" -nt "$BRIDGE" ]; then
|
||||||
echo "[chanora_bridge script_phase] framework already up-to-date"
|
echo "[chanora_bridge script_phase] framework already up-to-date"
|
||||||
FW_UP_TO_DATE=1
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$FW_UP_TO_DATE" = 0 ]; then
|
mkdir -p "$FW"
|
||||||
mkdir -p "$FW"
|
cp "$BRIDGE" "$FW/chanora_bridge"
|
||||||
cp "$BRIDGE" "$FW/chanora_bridge"
|
cat > "$FW/Info.plist" <<PLIST
|
||||||
cat > "$FW/Info.plist" <<PLIST
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
@@ -272,28 +234,9 @@ PLIST
|
|||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
||||||
"$FW/chanora_bridge"
|
"$FW/chanora_bridge"
|
||||||
rm -rf "$FW.dSYM"
|
echo "[chanora_bridge script_phase] framework refreshed"
|
||||||
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
|
SCRIPT
|
||||||
:execution_position => :before_compile,
|
:execution_position => :before_compile,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
// Canonical layout breakpoints for Chanora.
|
|
||||||
//
|
|
||||||
// Aligned with Material 3 adaptive layout guidance:
|
|
||||||
// compact < 600dp — phone, narrow tablet
|
|
||||||
// medium 600–1023 — tablet portrait, small desktop window
|
|
||||||
// expanded ≥ 1024dp — desktop, tablet landscape
|
|
||||||
//
|
|
||||||
// 1024dp was chosen as the expanded threshold based on production app
|
|
||||||
// research: Discord (member list at 1024px), Mattermost (RHS docked at
|
|
||||||
// ≥ 1024px), and Rocket.Chat (contextual bar persistent at lg/1024px).
|
|
||||||
|
|
||||||
/// Canonical breakpoint thresholds in logical pixels.
|
|
||||||
///
|
|
||||||
/// Use these instead of hardcoded pixel values in layout decisions.
|
|
||||||
/// Migrate existing `_wideBreakpoint` / `_chatMobileBreakpoint` references
|
|
||||||
/// to these named constants.
|
|
||||||
class ChanoraBreakpoints {
|
|
||||||
ChanoraBreakpoints._();
|
|
||||||
|
|
||||||
/// Width at which the layout switches from compact to medium.
|
|
||||||
/// Below this: single-column mobile layout.
|
|
||||||
/// At/above: two-panel side-by-side layout.
|
|
||||||
static const double medium = 600;
|
|
||||||
|
|
||||||
/// Width at which the layout switches from medium to expanded.
|
|
||||||
/// Below this: chat opens as a pushed route.
|
|
||||||
/// At/above: three-panel layout with inline chat panel.
|
|
||||||
static const double expanded = 1024;
|
|
||||||
|
|
||||||
// Panel sizing constants.
|
|
||||||
|
|
||||||
/// Fixed width of the left voice/control panel.
|
|
||||||
static const double voicePanelWidth = 320;
|
|
||||||
|
|
||||||
/// Fixed width of the right chat panel (expanded layout only).
|
|
||||||
static const double chatPanelWidth = 380;
|
|
||||||
|
|
||||||
/// Horizontal gap between panels.
|
|
||||||
static const double panelGap = 12;
|
|
||||||
|
|
||||||
/// Desktop snackbar width cap (used when width ≥ [medium]).
|
|
||||||
static const double snackBarDesktopCap = 560;
|
|
||||||
|
|
||||||
/// Connect form action buttons switch from row to column below this width.
|
|
||||||
static const double connectActionsStackMaxWidth = 400;
|
|
||||||
|
|
||||||
/// Modal bottom sheet max height as fraction of screen height.
|
|
||||||
static const double modalSheetHeightFraction = 0.72;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Semantic layout class derived from viewport width.
|
|
||||||
enum LayoutClass {
|
|
||||||
/// < 600dp — single-column mobile layout.
|
|
||||||
compact,
|
|
||||||
|
|
||||||
/// 600–1023dp — two-panel side-by-side layout.
|
|
||||||
medium,
|
|
||||||
|
|
||||||
/// ≥ 1024dp — three-panel layout with inline chat.
|
|
||||||
expanded,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Computes the current [LayoutClass] from viewport [width].
|
|
||||||
LayoutClass layoutClassFromWidth(double width) {
|
|
||||||
if (width >= ChanoraBreakpoints.expanded) return LayoutClass.expanded;
|
|
||||||
if (width >= ChanoraBreakpoints.medium) return LayoutClass.medium;
|
|
||||||
return LayoutClass.compact;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
// Viewport info inherited widget for Chanora.
|
|
||||||
//
|
|
||||||
// Computes [LayoutClass] once per frame from the current [MediaQuery] size
|
|
||||||
// and provides it to the entire widget subtree. Downstream widgets read
|
|
||||||
// `ViewportInfo.of(context)` instead of calling `LayoutBuilder` or
|
|
||||||
// `MediaQuery.sizeOf` directly for layout-class decisions.
|
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
|
|
||||||
import 'breakpoints.dart';
|
|
||||||
|
|
||||||
/// Inherited widget that exposes the current layout class and viewport
|
|
||||||
/// dimensions to the entire subtree.
|
|
||||||
///
|
|
||||||
/// Insert this once near the top of the widget tree (inside the Scaffold
|
|
||||||
/// body or equivalent). All descendants can then read
|
|
||||||
/// `ViewportInfo.of(context)` to determine their layout behaviour.
|
|
||||||
class ViewportInfo extends InheritedWidget {
|
|
||||||
/// Creates a [ViewportInfo].
|
|
||||||
const ViewportInfo({
|
|
||||||
super.key,
|
|
||||||
required this.layoutClass,
|
|
||||||
required this.width,
|
|
||||||
required this.height,
|
|
||||||
required super.child,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Current layout class derived from viewport width.
|
|
||||||
final LayoutClass layoutClass;
|
|
||||||
|
|
||||||
/// Current viewport width in logical pixels.
|
|
||||||
final double width;
|
|
||||||
|
|
||||||
/// Current viewport height in logical pixels.
|
|
||||||
final double height;
|
|
||||||
|
|
||||||
/// Returns the nearest [ViewportInfo] in the widget tree.
|
|
||||||
///
|
|
||||||
/// Asserts that a [ViewportInfo] ancestor exists.
|
|
||||||
static ViewportInfo of(BuildContext context) {
|
|
||||||
final info = context.dependOnInheritedWidgetOfExactType<ViewportInfo>();
|
|
||||||
assert(info != null, 'No ViewportInfo found in widget tree');
|
|
||||||
return info!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the current layout is compact (< 600dp).
|
|
||||||
bool get isCompact => layoutClass == LayoutClass.compact;
|
|
||||||
|
|
||||||
/// Whether the current layout is medium (600–1023dp).
|
|
||||||
bool get isMedium => layoutClass == LayoutClass.medium;
|
|
||||||
|
|
||||||
/// Whether the current layout is expanded (≥ 1024dp).
|
|
||||||
bool get isExpanded => layoutClass == LayoutClass.expanded;
|
|
||||||
|
|
||||||
/// Whether the layout has room for at least two panels (medium or expanded).
|
|
||||||
bool get isWide => !isCompact;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool updateShouldNotify(ViewportInfo old) => layoutClass != old.layoutClass;
|
|
||||||
// NOTE: width/height changes within the same layout class do NOT trigger
|
|
||||||
// notification. Dependents who genuinely need pixel-level dimensions
|
|
||||||
// (rare — most layouts should switch on layoutClass) must use a local
|
|
||||||
// LayoutBuilder. Notifying on every pixel would rebuild every dependent
|
|
||||||
// on every resize frame, which is the exact pessimisation this
|
|
||||||
// InheritedWidget exists to avoid.
|
|
||||||
}
|
|
||||||
@@ -46,7 +46,6 @@
|
|||||||
"retryAction": "Retry",
|
"retryAction": "Retry",
|
||||||
"chatAction": "Chat",
|
"chatAction": "Chat",
|
||||||
"chatCloseAction": "Close chat",
|
"chatCloseAction": "Close chat",
|
||||||
"chatPanelCollapsedHint": "Tap the chat button to continue your conversation",
|
|
||||||
"chatNewPrivateAction": "New private chat",
|
"chatNewPrivateAction": "New private chat",
|
||||||
"chatSearchClientsHint": "Search clients...",
|
"chatSearchClientsHint": "Search clients...",
|
||||||
"chatDirectMessageAction": "Private message",
|
"chatDirectMessageAction": "Private message",
|
||||||
@@ -299,6 +298,5 @@
|
|||||||
"clientVolumeMuteAction": "Mute user",
|
"clientVolumeMuteAction": "Mute user",
|
||||||
"clientVolumeUnmuteAction": "Unmute user",
|
"clientVolumeUnmuteAction": "Unmute user",
|
||||||
"clientVolumeResetAction": "Reset to default",
|
"clientVolumeResetAction": "Reset to default",
|
||||||
"permissionDenied": "Permission Denied",
|
"permissionDenied": "Permission Denied"
|
||||||
"voiceTalkPowerBlocked": "Insufficient talk power to speak in this channel"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
"retryAction": "重试",
|
"retryAction": "重试",
|
||||||
"chatAction": "聊天",
|
"chatAction": "聊天",
|
||||||
"chatCloseAction": "关闭聊天",
|
"chatCloseAction": "关闭聊天",
|
||||||
"chatPanelCollapsedHint": "点击聊天按钮以继续对话",
|
|
||||||
"chatNewPrivateAction": "新建私聊",
|
"chatNewPrivateAction": "新建私聊",
|
||||||
"chatSearchClientsHint": "搜索用户...",
|
"chatSearchClientsHint": "搜索用户...",
|
||||||
"chatDirectMessageAction": "私聊",
|
"chatDirectMessageAction": "私聊",
|
||||||
@@ -242,6 +241,5 @@
|
|||||||
"clientVolumeMuteAction": "静音该用户",
|
"clientVolumeMuteAction": "静音该用户",
|
||||||
"clientVolumeUnmuteAction": "取消静音",
|
"clientVolumeUnmuteAction": "取消静音",
|
||||||
"clientVolumeResetAction": "恢复默认",
|
"clientVolumeResetAction": "恢复默认",
|
||||||
"permissionDenied": "权限被拒绝",
|
"permissionDenied": "权限被拒绝"
|
||||||
"voiceTalkPowerBlocked": "发言权限不足,无法在此频道发言"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,12 +307,6 @@ abstract class AppL10n {
|
|||||||
/// **'Close chat'**
|
/// **'Close chat'**
|
||||||
String get chatCloseAction;
|
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.
|
/// No description provided for @chatNewPrivateAction.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1252,12 +1246,6 @@ abstract class AppL10n {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Permission Denied'**
|
/// **'Permission Denied'**
|
||||||
String get permissionDenied;
|
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> {
|
class _AppL10nDelegate extends LocalizationsDelegate<AppL10n> {
|
||||||
|
|||||||
@@ -123,10 +123,6 @@ class AppL10nEn extends AppL10n {
|
|||||||
@override
|
@override
|
||||||
String get chatCloseAction => 'Close chat';
|
String get chatCloseAction => 'Close chat';
|
||||||
|
|
||||||
@override
|
|
||||||
String get chatPanelCollapsedHint =>
|
|
||||||
'Tap the chat button to continue your conversation';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chatNewPrivateAction => 'New private chat';
|
String get chatNewPrivateAction => 'New private chat';
|
||||||
|
|
||||||
@@ -657,8 +653,4 @@ class AppL10nEn extends AppL10n {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get permissionDenied => 'Permission Denied';
|
String get permissionDenied => 'Permission Denied';
|
||||||
|
|
||||||
@override
|
|
||||||
String get voiceTalkPowerBlocked =>
|
|
||||||
'Insufficient talk power to speak in this channel';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,9 +120,6 @@ class AppL10nZh extends AppL10n {
|
|||||||
@override
|
@override
|
||||||
String get chatCloseAction => '关闭聊天';
|
String get chatCloseAction => '关闭聊天';
|
||||||
|
|
||||||
@override
|
|
||||||
String get chatPanelCollapsedHint => '点击聊天按钮以继续对话';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chatNewPrivateAction => '新建私聊';
|
String get chatNewPrivateAction => '新建私聊';
|
||||||
|
|
||||||
@@ -644,7 +641,4 @@ class AppL10nZh extends AppL10n {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get permissionDenied => '权限被拒绝';
|
String get permissionDenied => '权限被拒绝';
|
||||||
|
|
||||||
@override
|
|
||||||
String get voiceTalkPowerBlocked => '发言权限不足,无法在此频道发言';
|
|
||||||
}
|
}
|
||||||
|
|||||||
+351
-610
File diff suppressed because it is too large
Load Diff
@@ -6,22 +6,7 @@ import '../src/rust/api.dart' as rust;
|
|||||||
|
|
||||||
const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle';
|
const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle';
|
||||||
const androidAudioLifecycleChannelName = 'chanora/android_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) {
|
rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'Earpiece':
|
case 'Earpiece':
|
||||||
@@ -29,15 +14,11 @@ rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
|
|||||||
case 'Speaker':
|
case 'Speaker':
|
||||||
return rust.BridgeAudioRoute.speaker;
|
return rust.BridgeAudioRoute.speaker;
|
||||||
case 'WiredHeadset':
|
case 'WiredHeadset':
|
||||||
case 'UsbHeadset':
|
|
||||||
return rust.BridgeAudioRoute.wiredHeadset;
|
return rust.BridgeAudioRoute.wiredHeadset;
|
||||||
case 'BluetoothHfp':
|
case 'BluetoothHfp':
|
||||||
return rust.BridgeAudioRoute.bluetoothHfp;
|
return rust.BridgeAudioRoute.bluetoothHfp;
|
||||||
case 'BluetoothA2dp':
|
case 'BluetoothA2dp':
|
||||||
return rust.BridgeAudioRoute.bluetoothA2Dp;
|
return rust.BridgeAudioRoute.bluetoothA2Dp;
|
||||||
case 'Hdmi':
|
|
||||||
case 'Unknown':
|
|
||||||
return rust.BridgeAudioRoute.unknown;
|
|
||||||
default:
|
default:
|
||||||
return rust.BridgeAudioRoute.unknown;
|
return rust.BridgeAudioRoute.unknown;
|
||||||
}
|
}
|
||||||
@@ -46,7 +27,6 @@ rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
|
|||||||
void wireAudioLifecycle() {
|
void wireAudioLifecycle() {
|
||||||
wireIosAudioLifecycle();
|
wireIosAudioLifecycle();
|
||||||
wireAndroidAudioLifecycle();
|
wireAndroidAudioLifecycle();
|
||||||
wireMacosAudioLifecycle();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
|
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
|
||||||
@@ -125,42 +105,3 @@ void wireAndroidAudioLifecycle({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire the macOS audio lifecycle MethodChannel.
|
|
||||||
///
|
|
||||||
/// Swift side (`MacOSAudioLifecycle`) posts `handleDefaultDeviceChange`
|
|
||||||
/// (with `role: 'input' | 'output'`) when Core Audio HAL default-input /
|
|
||||||
/// default-output device changes, and `handleConfigurationChange` when
|
|
||||||
/// the VPIO AudioUnit reports a stream-format change. Closes the
|
|
||||||
/// iOS/macOS asymmetry noted in SysRS-051.
|
|
||||||
///
|
|
||||||
/// Current scope: events are received and logged. The FRB
|
|
||||||
/// `macosDefaultDeviceChanged` function that triggers a VPIO
|
|
||||||
/// re-bind on the engine is a follow-up; until it's exposed, the
|
|
||||||
/// macOS path mirrors the iOS `chanora/ios_audio_lifecycle` event
|
|
||||||
/// surface but does not yet trigger an engine-side restart.
|
|
||||||
void wireMacosAudioLifecycle({
|
|
||||||
bool isMacos = false,
|
|
||||||
MethodChannel channel = const MethodChannel(macosAudioLifecycleChannelName),
|
|
||||||
}) {
|
|
||||||
if (!isMacos && !Platform.isMacOS) return;
|
|
||||||
channel.setMethodCallHandler((call) async {
|
|
||||||
try {
|
|
||||||
switch (call.method) {
|
|
||||||
case 'handleDefaultDeviceChange':
|
|
||||||
// TODO: call rust.macosDefaultDeviceChanged() once exposed
|
|
||||||
// via flutter_rust_bridge; until then the event is captured
|
|
||||||
// here for observability.
|
|
||||||
break;
|
|
||||||
case 'handleConfigurationChange':
|
|
||||||
// TODO: same — currently captured, no engine action yet.
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Errors from the Rust side are already logged there; do not propagate
|
|
||||||
// exceptions to the platform framework.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
class HardMuteOwners {
|
|
||||||
const HardMuteOwners({
|
|
||||||
this.manual = false,
|
|
||||||
this.permission = false,
|
|
||||||
this.talkPower = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
final bool manual;
|
|
||||||
final bool permission;
|
|
||||||
final bool talkPower;
|
|
||||||
|
|
||||||
bool get effective => manual || permission || talkPower;
|
|
||||||
|
|
||||||
HardMuteOwners withBridgeManualMute(bool muted) {
|
|
||||||
return copyWith(
|
|
||||||
manual: muted && (manual || !permission && !talkPower),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
HardMuteOwners copyWith({
|
|
||||||
bool? manual,
|
|
||||||
bool? permission,
|
|
||||||
bool? talkPower,
|
|
||||||
}) {
|
|
||||||
return HardMuteOwners(
|
|
||||||
manual: manual ?? this.manual,
|
|
||||||
permission: permission ?? this.permission,
|
|
||||||
talkPower: talkPower ?? this.talkPower,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import 'dart:io' show Platform;
|
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
const iosAudioSessionChannelName = 'chanora/ios_audio_session';
|
|
||||||
|
|
||||||
/// Controls the iOS AVAudioSession VoIP lifecycle from Dart.
|
|
||||||
///
|
|
||||||
/// The Swift `AppDelegate` configures the session to `.ambient` at
|
|
||||||
/// launch and leaves it inactive. The session is only switched to
|
|
||||||
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
|
|
||||||
/// voice channel is actually active. This controller is the Dart
|
|
||||||
/// side of that contract — call [activate] 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();
|
|
||||||
@@ -63,8 +63,6 @@ const String methodTriggerLocalNetworkPrompt = 'triggerLocalNetworkPrompt';
|
|||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
const String methodCheckLocalNetwork = 'checkLocalNetwork';
|
const String methodCheckLocalNetwork = 'checkLocalNetwork';
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
const String methodCheckLocalNetworkAccess = 'checkLocalNetworkAccess';
|
|
||||||
@visibleForTesting
|
|
||||||
const String methodRequestNotifications = 'requestNotifications';
|
const String methodRequestNotifications = 'requestNotifications';
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
const String methodCheckNotifications = 'checkNotifications';
|
const String methodCheckNotifications = 'checkNotifications';
|
||||||
@@ -311,14 +309,12 @@ class MacOSPermissionsService {
|
|||||||
_inputMonitoringState.value = state;
|
_inputMonitoringState.value = state;
|
||||||
_pttCapabilityState.value = _pttCapabilityLevel(state);
|
_pttCapabilityState.value = _pttCapabilityLevel(state);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
case methodLocalNetworkStateChanged:
|
case methodLocalNetworkStateChanged:
|
||||||
final args = call.arguments;
|
final args = call.arguments;
|
||||||
if (args is Map) {
|
if (args is Map) {
|
||||||
_localNetworkState.value =
|
_localNetworkState.value =
|
||||||
_parseLocalNetworkState(args['state'] as String?);
|
_parseLocalNetworkState(args['state'] as String?);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -442,37 +438,6 @@ class MacOSPermissionsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe whether Local Network access is currently denied for [host]:[port]
|
|
||||||
/// by creating a short-lived NWConnection and checking
|
|
||||||
/// `unsatisfiedReason == .localNetworkDenied`.
|
|
||||||
///
|
|
||||||
/// This does NOT trigger a new system prompt — it is a read-only check.
|
|
||||||
/// Returns [MacOSLocalNetworkState.unsupported] on non-macOS platforms.
|
|
||||||
Future<MacOSLocalNetworkState> checkLocalNetworkAccess({
|
|
||||||
required String host,
|
|
||||||
required int port,
|
|
||||||
}) async {
|
|
||||||
final ch = _channel;
|
|
||||||
if (ch == null) return MacOSLocalNetworkState.unsupported;
|
|
||||||
try {
|
|
||||||
final raw = await ch.invokeMethod<String>(
|
|
||||||
methodCheckLocalNetworkAccess,
|
|
||||||
<String, dynamic>{'host': host, 'port': port},
|
|
||||||
);
|
|
||||||
final state = _parseLocalNetworkState(raw);
|
|
||||||
_localNetworkState.value = state;
|
|
||||||
return state;
|
|
||||||
} catch (e, st) {
|
|
||||||
developer.log(
|
|
||||||
'checkLocalNetworkAccess failed',
|
|
||||||
name: 'MacOSPermissionsService',
|
|
||||||
error: e,
|
|
||||||
stackTrace: st,
|
|
||||||
);
|
|
||||||
return _localNetworkState.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Outbound: Notifications ----------------------------------------------
|
// -- Outbound: Notifications ----------------------------------------------
|
||||||
|
|
||||||
Future<MacOSPermissionState> _checkNotifications() async {
|
Future<MacOSPermissionState> _checkNotifications() async {
|
||||||
|
|||||||
@@ -261,12 +261,6 @@ Stream<BridgeEvent> eventsStream() =>
|
|||||||
Future<BridgeAudioStats> audioStats() =>
|
Future<BridgeAudioStats> audioStats() =>
|
||||||
RustLib.instance.api.crateApiAudioStats();
|
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.
|
/// Apply the P1 audio-processing config.
|
||||||
Future<void> setAudioProcessingConfig({
|
Future<void> setAudioProcessingConfig({
|
||||||
required BridgeAudioProcessingConfig config,
|
required BridgeAudioProcessingConfig config,
|
||||||
@@ -687,22 +681,15 @@ class BridgeAudioStats {
|
|||||||
/// Current push-to-talk state.
|
/// Current push-to-talk state.
|
||||||
final bool pttActive;
|
final bool pttActive;
|
||||||
|
|
||||||
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
|
|
||||||
final double inputLevel;
|
|
||||||
|
|
||||||
const BridgeAudioStats({
|
const BridgeAudioStats({
|
||||||
required this.framesSent,
|
required this.framesSent,
|
||||||
required this.framesReceived,
|
required this.framesReceived,
|
||||||
required this.pttActive,
|
required this.pttActive,
|
||||||
required this.inputLevel,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
framesSent.hashCode ^
|
framesSent.hashCode ^ framesReceived.hashCode ^ pttActive.hashCode;
|
||||||
framesReceived.hashCode ^
|
|
||||||
pttActive.hashCode ^
|
|
||||||
inputLevel.hashCode;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -711,8 +698,7 @@ class BridgeAudioStats {
|
|||||||
runtimeType == other.runtimeType &&
|
runtimeType == other.runtimeType &&
|
||||||
framesSent == other.framesSent &&
|
framesSent == other.framesSent &&
|
||||||
framesReceived == other.framesReceived &&
|
framesReceived == other.framesReceived &&
|
||||||
pttActive == other.pttActive &&
|
pttActive == other.pttActive;
|
||||||
inputLevel == other.inputLevel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bookmark DTO mirroring [`chanora_core::Bookmark`].
|
/// Bookmark DTO mirroring [`chanora_core::Bookmark`].
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
|||||||
String get codegenVersion => '2.12.0';
|
String get codegenVersion => '2.12.0';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get rustContentHash => -20394775;
|
int get rustContentHash => 281698435;
|
||||||
|
|
||||||
static const kDefaultExternalLibraryLoaderConfig =
|
static const kDefaultExternalLibraryLoaderConfig =
|
||||||
ExternalLibraryLoaderConfig(
|
ExternalLibraryLoaderConfig(
|
||||||
@@ -123,8 +123,6 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
Future<void> crateApiInitStorage({required String dir});
|
Future<void> crateApiInitStorage({required String dir});
|
||||||
|
|
||||||
Stream<double> crateApiInputLevelStream();
|
|
||||||
|
|
||||||
Future<bool> crateApiIsConnected();
|
Future<bool> crateApiIsConnected();
|
||||||
|
|
||||||
Future<BridgeAudioDeviceList> crateApiListAudioDevices();
|
Future<BridgeAudioDeviceList> crateApiListAudioDevices();
|
||||||
@@ -764,38 +762,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiInitStorageConstMeta =>
|
TaskConstMeta get kCrateApiInitStorageConstMeta =>
|
||||||
const TaskConstMeta(debugName: "init_storage", argNames: ["dir"]);
|
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
|
@override
|
||||||
Future<bool> crateApiIsConnected() {
|
Future<bool> crateApiIsConnected() {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
@@ -805,7 +771,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 22,
|
funcId: 21,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -832,7 +798,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 23,
|
funcId: 22,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -859,7 +825,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 24,
|
funcId: 23,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -883,7 +849,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
SyncTask(
|
SyncTask(
|
||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_String,
|
decodeSuccessData: sse_decode_String,
|
||||||
@@ -913,7 +879,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 26,
|
funcId: 25,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -943,7 +909,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 27,
|
funcId: 26,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -970,7 +936,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 28,
|
funcId: 27,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -995,7 +961,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_String(state, serializer);
|
sse_encode_String(state, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -1028,7 +994,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 30,
|
funcId: 29,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1055,7 +1021,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_bridge_audio_route(route, serializer);
|
sse_encode_bridge_audio_route(route, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -1089,7 +1055,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 32,
|
funcId: 31,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1124,7 +1090,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 33,
|
funcId: 32,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1154,7 +1120,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 34,
|
funcId: 33,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1182,7 +1148,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 35,
|
funcId: 34,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1210,7 +1176,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 36,
|
funcId: 35,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1240,7 +1206,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 37,
|
funcId: 36,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1268,7 +1234,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_bridge_network_state(state, serializer);
|
sse_encode_bridge_network_state(state, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -1294,7 +1260,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 39,
|
funcId: 38,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1322,7 +1288,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 40,
|
funcId: 39,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1350,7 +1316,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 41,
|
funcId: 40,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1378,7 +1344,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 42,
|
funcId: 41,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1410,7 +1376,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 43,
|
funcId: 42,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1440,7 +1406,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 44,
|
funcId: 43,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1468,7 +1434,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 45,
|
funcId: 44,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1496,7 +1462,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 46,
|
funcId: 45,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1523,7 +1489,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 47,
|
funcId: 46,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1551,7 +1517,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 48,
|
funcId: 47,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1583,7 +1549,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 49,
|
funcId: 48,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1612,7 +1578,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 50,
|
funcId: 49,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1644,12 +1610,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
throw UnimplementedError();
|
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
|
@protected
|
||||||
String dco_decode_String(dynamic raw) {
|
String dco_decode_String(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
@@ -1821,13 +1781,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) {
|
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
final arr = raw as List<dynamic>;
|
final arr = raw as List<dynamic>;
|
||||||
if (arr.length != 4)
|
if (arr.length != 3)
|
||||||
throw Exception('unexpected arr length: expect 4 but see ${arr.length}');
|
throw Exception('unexpected arr length: expect 3 but see ${arr.length}');
|
||||||
return BridgeAudioStats(
|
return BridgeAudioStats(
|
||||||
framesSent: dco_decode_u_32(arr[0]),
|
framesSent: dco_decode_u_32(arr[0]),
|
||||||
framesReceived: dco_decode_u_32(arr[1]),
|
framesReceived: dco_decode_u_32(arr[1]),
|
||||||
pttActive: dco_decode_bool(arr[2]),
|
pttActive: dco_decode_bool(arr[2]),
|
||||||
inputLevel: dco_decode_f_32(arr[3]),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2312,14 +2271,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
throw UnimplementedError('Unreachable ()');
|
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
|
@protected
|
||||||
String sse_decode_String(SseDeserializer deserializer) {
|
String sse_decode_String(SseDeserializer deserializer) {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
@@ -2537,12 +2488,10 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
var var_framesSent = sse_decode_u_32(deserializer);
|
var var_framesSent = sse_decode_u_32(deserializer);
|
||||||
var var_framesReceived = sse_decode_u_32(deserializer);
|
var var_framesReceived = sse_decode_u_32(deserializer);
|
||||||
var var_pttActive = sse_decode_bool(deserializer);
|
var var_pttActive = sse_decode_bool(deserializer);
|
||||||
var var_inputLevel = sse_decode_f_32(deserializer);
|
|
||||||
return BridgeAudioStats(
|
return BridgeAudioStats(
|
||||||
framesSent: var_framesSent,
|
framesSent: var_framesSent,
|
||||||
framesReceived: var_framesReceived,
|
framesReceived: var_framesReceived,
|
||||||
pttActive: var_pttActive,
|
pttActive: var_pttActive,
|
||||||
inputLevel: var_inputLevel,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3250,23 +3199,6 @@ 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
|
@protected
|
||||||
void sse_encode_String(String self, SseSerializer serializer) {
|
void sse_encode_String(String self, SseSerializer serializer) {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
@@ -3448,7 +3380,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
sse_encode_u_32(self.framesSent, serializer);
|
sse_encode_u_32(self.framesSent, serializer);
|
||||||
sse_encode_u_32(self.framesReceived, serializer);
|
sse_encode_u_32(self.framesReceived, serializer);
|
||||||
sse_encode_bool(self.pttActive, serializer);
|
sse_encode_bool(self.pttActive, serializer);
|
||||||
sse_encode_f_32(self.inputLevel, serializer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
dynamic raw,
|
dynamic raw,
|
||||||
);
|
);
|
||||||
|
|
||||||
@protected
|
|
||||||
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw);
|
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
String dco_decode_String(dynamic raw);
|
String dco_decode_String(dynamic raw);
|
||||||
|
|
||||||
@@ -213,11 +210,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
@protected
|
|
||||||
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
|
|
||||||
SseDeserializer deserializer,
|
|
||||||
);
|
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
String sse_decode_String(SseDeserializer deserializer);
|
String sse_decode_String(SseDeserializer deserializer);
|
||||||
|
|
||||||
@@ -447,12 +439,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
@protected
|
|
||||||
void sse_encode_StreamSink_f_32_Sse(
|
|
||||||
RustStreamSink<double> self,
|
|
||||||
SseSerializer serializer,
|
|
||||||
);
|
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_String(String self, SseSerializer serializer);
|
void sse_encode_String(String self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
dynamic raw,
|
dynamic raw,
|
||||||
);
|
);
|
||||||
|
|
||||||
@protected
|
|
||||||
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw);
|
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
String dco_decode_String(dynamic raw);
|
String dco_decode_String(dynamic raw);
|
||||||
|
|
||||||
@@ -215,11 +212,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
@protected
|
|
||||||
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
|
|
||||||
SseDeserializer deserializer,
|
|
||||||
);
|
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
String sse_decode_String(SseDeserializer deserializer);
|
String sse_decode_String(SseDeserializer deserializer);
|
||||||
|
|
||||||
@@ -449,12 +441,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
@protected
|
|
||||||
void sse_encode_StreamSink_f_32_Sse(
|
|
||||||
RustStreamSink<double> self,
|
|
||||||
SseSerializer serializer,
|
|
||||||
);
|
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_String(String self, SseSerializer serializer);
|
void sse_encode_String(String self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../design/breakpoints.dart';
|
|
||||||
|
|
||||||
/// Semantic tones for lightweight, Material 3 SnackBars.
|
/// Semantic tones for lightweight, Material 3 SnackBars.
|
||||||
enum AppSnackBarVariant { neutral, success, warning, error }
|
enum AppSnackBarVariant { neutral, success, warning, error }
|
||||||
|
|
||||||
@@ -9,6 +7,7 @@ enum AppSnackBarVariant { neutral, success, warning, error }
|
|||||||
class AppSnackBar {
|
class AppSnackBar {
|
||||||
const AppSnackBar._();
|
const AppSnackBar._();
|
||||||
|
|
||||||
|
static const double _desktopMaxWidth = 560;
|
||||||
static const double _radius = 16;
|
static const double _radius = 16;
|
||||||
static const double _elevation = 3;
|
static const double _elevation = 3;
|
||||||
|
|
||||||
@@ -40,9 +39,9 @@ class AppSnackBar {
|
|||||||
}) {
|
}) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final viewWidth = MediaQuery.sizeOf(context).width;
|
final viewWidth = MediaQuery.sizeOf(context).width;
|
||||||
final useDesktopCap = viewWidth >= ChanoraBreakpoints.medium;
|
final useDesktopCap = viewWidth >= 600;
|
||||||
final snackBarWidth = useDesktopCap && margin == null
|
final snackBarWidth = useDesktopCap && margin == null
|
||||||
? ChanoraBreakpoints.snackBarDesktopCap
|
? _desktopMaxWidth
|
||||||
: null;
|
: null;
|
||||||
final effectiveMargin = useDesktopCap && margin != null
|
final effectiveMargin = useDesktopCap && margin != null
|
||||||
? _desktopCappedMargin(context, margin)
|
? _desktopCappedMargin(context, margin)
|
||||||
@@ -77,11 +76,7 @@ class AppSnackBar {
|
|||||||
final viewWidth = MediaQuery.sizeOf(context).width;
|
final viewWidth = MediaQuery.sizeOf(context).width;
|
||||||
final resolved = margin.resolve(Directionality.of(context));
|
final resolved = margin.resolve(Directionality.of(context));
|
||||||
final extraHorizontal =
|
final extraHorizontal =
|
||||||
(viewWidth - ChanoraBreakpoints.snackBarDesktopCap).clamp(
|
(viewWidth - _desktopMaxWidth).clamp(0.0, viewWidth) / 2;
|
||||||
0.0,
|
|
||||||
viewWidth,
|
|
||||||
) /
|
|
||||||
2;
|
|
||||||
return EdgeInsets.fromLTRB(
|
return EdgeInsets.fromLTRB(
|
||||||
resolved.left + extraHorizontal,
|
resolved.left + extraHorizontal,
|
||||||
resolved.top,
|
resolved.top,
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../design/breakpoints.dart';
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
|
||||||
import '../services/snapshot_state_mapper.dart';
|
|
||||||
import '../services/ts3_server_link.dart';
|
|
||||||
import '../src/rust/api.dart' as rust;
|
|
||||||
import 'chat_views.dart';
|
|
||||||
|
|
||||||
/// Fixed-width inline chat panel for expanded desktop layouts.
|
|
||||||
class ChatPanel extends StatelessWidget {
|
|
||||||
/// Construct an inline chat panel.
|
|
||||||
const ChatPanel({
|
|
||||||
super.key,
|
|
||||||
required this.messages,
|
|
||||||
required this.snapshot,
|
|
||||||
required this.target,
|
|
||||||
required this.clientName,
|
|
||||||
required this.onClose,
|
|
||||||
this.restoredDraft,
|
|
||||||
this.onDraftChanged,
|
|
||||||
this.onTs3ServerLink,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Backing chat messages shared with the chat route.
|
|
||||||
final List<ChatEntry> messages;
|
|
||||||
|
|
||||||
/// Latest TeamSpeak snapshot.
|
|
||||||
final rust.BridgeSnapshot snapshot;
|
|
||||||
|
|
||||||
/// Chat target shown in the panel.
|
|
||||||
final rust.BridgeMessageTarget target;
|
|
||||||
|
|
||||||
/// Client display name for direct-message and poke targets.
|
|
||||||
final String clientName;
|
|
||||||
|
|
||||||
/// Handle TeamSpeak server links embedded in chat messages.
|
|
||||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
|
||||||
|
|
||||||
/// Called when the user closes the inline panel.
|
|
||||||
final VoidCallback onClose;
|
|
||||||
|
|
||||||
/// External draft text to restore in the chat detail view.
|
|
||||||
final String? restoredDraft;
|
|
||||||
|
|
||||||
/// Called when the draft text changes.
|
|
||||||
final ValueChanged<String>? onDraftChanged;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final currentChannelId = ownClientSnapshotState(snapshot)?.channelId;
|
|
||||||
final channelName = snapshotChannelName(snapshot, currentChannelId);
|
|
||||||
final l10n = AppL10n.of(context);
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
width: ChanoraBreakpoints.chatPanelWidth,
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surface,
|
|
||||||
border: BorderDirectional(
|
|
||||||
start: BorderSide(
|
|
||||||
color: Theme.of(context).colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ChatDetailView(
|
|
||||||
messages: messages,
|
|
||||||
snapshot: snapshot,
|
|
||||||
target: target,
|
|
||||||
clientName: clientName,
|
|
||||||
currentChannelId: currentChannelId,
|
|
||||||
channelName: channelName,
|
|
||||||
onTs3ServerLink: onTs3ServerLink,
|
|
||||||
restoredDraft: restoredDraft,
|
|
||||||
onDraftChanged: onDraftChanged,
|
|
||||||
messageMaxWidth: 500,
|
|
||||||
headerTrailing: IconButton(
|
|
||||||
tooltip: l10n.chatCloseAction,
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: onClose,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import 'dart:async' show unawaited;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../design/breakpoints.dart';
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
import '../services/channel_spacer.dart';
|
import '../services/channel_spacer.dart';
|
||||||
import '../services/link_trust_service.dart';
|
import '../services/link_trust_service.dart';
|
||||||
@@ -14,6 +13,7 @@ import 'bbcode_text.dart';
|
|||||||
const double _chatSidebarTileExtent = 92;
|
const double _chatSidebarTileExtent = 92;
|
||||||
const double _chatSidebarCompactTileExtent = 76;
|
const double _chatSidebarCompactTileExtent = 76;
|
||||||
const double _chatSidebarCompactHeight = 84;
|
const double _chatSidebarCompactHeight = 84;
|
||||||
|
const double _chatMobileBreakpoint = 600;
|
||||||
|
|
||||||
/// One chat/activity message shown in the chat hub.
|
/// One chat/activity message shown in the chat hub.
|
||||||
class ChatEntry {
|
class ChatEntry {
|
||||||
@@ -616,7 +616,7 @@ class _ChatPageState extends State<ChatPage> {
|
|||||||
final currentChannelId = _currentChannelId;
|
final currentChannelId = _currentChannelId;
|
||||||
final channelName = snapshotChannelName(snapshot, currentChannelId);
|
final channelName = snapshotChannelName(snapshot, currentChannelId);
|
||||||
final l10n = AppL10n.of(context);
|
final l10n = AppL10n.of(context);
|
||||||
final detail = ChatDetailView(
|
final detail = _ChatDetailView(
|
||||||
target: _selectedTarget,
|
target: _selectedTarget,
|
||||||
clientName: _selectedClientName,
|
clientName: _selectedClientName,
|
||||||
snapshot: snapshot,
|
snapshot: snapshot,
|
||||||
@@ -641,7 +641,7 @@ class _ChatPageState extends State<ChatPage> {
|
|||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final sidebar = _ChatSidebar(
|
final sidebar = _ChatSidebar(
|
||||||
compact: constraints.maxWidth < ChanoraBreakpoints.medium,
|
compact: constraints.maxWidth < _chatMobileBreakpoint,
|
||||||
selectedTarget: _selectedTarget,
|
selectedTarget: _selectedTarget,
|
||||||
privateChats: _privateChats,
|
privateChats: _privateChats,
|
||||||
onSelect: _selectTarget,
|
onSelect: _selectTarget,
|
||||||
@@ -650,7 +650,7 @@ class _ChatPageState extends State<ChatPage> {
|
|||||||
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
|
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (constraints.maxWidth < ChanoraBreakpoints.medium) {
|
if (constraints.maxWidth < _chatMobileBreakpoint) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
sidebar,
|
sidebar,
|
||||||
@@ -1050,11 +1050,8 @@ class _ChannelGroup extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detail view for a single chat target, including message history and input.
|
class _ChatDetailView extends StatefulWidget {
|
||||||
class ChatDetailView extends StatefulWidget {
|
const _ChatDetailView({
|
||||||
/// Construct a chat detail view.
|
|
||||||
const ChatDetailView({
|
|
||||||
super.key,
|
|
||||||
required this.target,
|
required this.target,
|
||||||
required this.clientName,
|
required this.clientName,
|
||||||
required this.snapshot,
|
required this.snapshot,
|
||||||
@@ -1062,50 +1059,21 @@ class ChatDetailView extends StatefulWidget {
|
|||||||
required this.currentChannelId,
|
required this.currentChannelId,
|
||||||
required this.channelName,
|
required this.channelName,
|
||||||
this.onTs3ServerLink,
|
this.onTs3ServerLink,
|
||||||
this.headerTrailing,
|
|
||||||
this.messageMaxWidth,
|
|
||||||
this.restoredDraft,
|
|
||||||
this.onDraftChanged,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Chat target displayed by this detail view.
|
|
||||||
final rust.BridgeMessageTarget target;
|
final rust.BridgeMessageTarget target;
|
||||||
|
|
||||||
/// Client display name for direct-message and poke targets.
|
|
||||||
final String clientName;
|
final String clientName;
|
||||||
|
|
||||||
/// Latest TeamSpeak snapshot.
|
|
||||||
final rust.BridgeSnapshot snapshot;
|
final rust.BridgeSnapshot snapshot;
|
||||||
|
|
||||||
/// Backing message list. Self-sent messages are appended here.
|
|
||||||
final List<ChatEntry> messages;
|
final List<ChatEntry> messages;
|
||||||
|
|
||||||
/// Current voice channel id for channel-chat send gating.
|
|
||||||
final BigInt? currentChannelId;
|
final BigInt? currentChannelId;
|
||||||
|
|
||||||
/// Current voice channel name for labels and placeholders.
|
|
||||||
final String channelName;
|
final String channelName;
|
||||||
|
|
||||||
/// Handle TeamSpeak server links embedded in chat messages.
|
|
||||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
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
|
@override
|
||||||
State<ChatDetailView> createState() => _ChatDetailViewState();
|
State<_ChatDetailView> createState() => _ChatDetailViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChatDetailViewState extends State<ChatDetailView> {
|
class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||||
final _textCtl = TextEditingController();
|
final _textCtl = TextEditingController();
|
||||||
final _scrollCtl = ScrollController();
|
final _scrollCtl = ScrollController();
|
||||||
int _lastRenderedMessageCount = -1;
|
int _lastRenderedMessageCount = -1;
|
||||||
@@ -1132,35 +1100,8 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
|||||||
clientName: widget.clientName,
|
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
|
@override
|
||||||
void dispose() {
|
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();
|
_textCtl.dispose();
|
||||||
_scrollCtl.dispose();
|
_scrollCtl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -1235,12 +1176,7 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
|||||||
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
|
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Text(_title, style: theme.textTheme.titleMedium),
|
||||||
children: [
|
|
||||||
Expanded(child: Text(_title, style: theme.textTheme.titleMedium)),
|
|
||||||
if (widget.headerTrailing != null) widget.headerTrailing!,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: msgs.isEmpty
|
child: msgs.isEmpty
|
||||||
@@ -1280,16 +1216,9 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
|||||||
controller: _scrollCtl,
|
controller: _scrollCtl,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
itemCount: msgs.length,
|
itemCount: msgs.length,
|
||||||
itemBuilder: (_, i) => Center(
|
itemBuilder: (_, i) => _MessageBubble(
|
||||||
child: ConstrainedBox(
|
entry: msgs[i],
|
||||||
constraints: BoxConstraints(
|
onTs3ServerLink: widget.onTs3ServerLink,
|
||||||
maxWidth: widget.messageMaxWidth ?? double.infinity,
|
|
||||||
),
|
|
||||||
child: _MessageBubble(
|
|
||||||
entry: msgs[i],
|
|
||||||
onTs3ServerLink: widget.onTs3ServerLink,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../design/breakpoints.dart';
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
import '../src/rust/api.dart' as rust;
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
@@ -115,6 +114,7 @@ class _ConnectFormState extends State<ConnectForm> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
LayoutBuilder(
|
LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
const stackedActionsMaxWidth = 400.0;
|
||||||
final connectButton = FilledButton.icon(
|
final connectButton = FilledButton.icon(
|
||||||
icon: const Icon(Icons.login),
|
icon: const Icon(Icons.login),
|
||||||
label: Text(l10n.connectAction),
|
label: Text(l10n.connectAction),
|
||||||
@@ -125,8 +125,7 @@ class _ConnectFormState extends State<ConnectForm> {
|
|||||||
label: Text(l10n.bookmarkAddAction),
|
label: Text(l10n.bookmarkAddAction),
|
||||||
onPressed: widget.onAddBookmark,
|
onPressed: widget.onAddBookmark,
|
||||||
);
|
);
|
||||||
if (constraints.maxWidth <=
|
if (constraints.maxWidth <= stackedActionsMaxWidth) {
|
||||||
ChanoraBreakpoints.connectActionsStackMaxWidth) {
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -23,14 +23,12 @@ class SnapshotView extends StatefulWidget {
|
|||||||
required this.localOutputMuted,
|
required this.localOutputMuted,
|
||||||
required this.hasJoinPending,
|
required this.hasJoinPending,
|
||||||
required this.canJoinVoiceChannel,
|
required this.canJoinVoiceChannel,
|
||||||
required this.unreadChannelIds,
|
|
||||||
required this.onJoinChannel,
|
required this.onJoinChannel,
|
||||||
required this.onJoinChannelWithPassword,
|
required this.onJoinChannelWithPassword,
|
||||||
this.enableClientLongPressMenu = false,
|
this.enableClientLongPressMenu = false,
|
||||||
this.onOpenClientInfo,
|
this.onOpenClientInfo,
|
||||||
this.onOpenClientChat,
|
this.onOpenClientChat,
|
||||||
this.onOpenClientPoke,
|
this.onOpenClientPoke,
|
||||||
this.onOpenChannelChat,
|
|
||||||
this.onTs3ServerLink,
|
this.onTs3ServerLink,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,9 +56,6 @@ class SnapshotView extends StatefulWidget {
|
|||||||
/// True when the local client may join voice channels.
|
/// True when the local client may join voice channels.
|
||||||
final bool canJoinVoiceChannel;
|
final bool canJoinVoiceChannel;
|
||||||
|
|
||||||
/// Set of channel IDs that have unread chat messages.
|
|
||||||
final Set<BigInt> unreadChannelIds;
|
|
||||||
|
|
||||||
/// Join an unlocked channel.
|
/// Join an unlocked channel.
|
||||||
final ValueChanged<rust.BridgeChannel> onJoinChannel;
|
final ValueChanged<rust.BridgeChannel> onJoinChannel;
|
||||||
|
|
||||||
@@ -79,9 +74,6 @@ class SnapshotView extends StatefulWidget {
|
|||||||
/// Open a poke composer for a non-self client.
|
/// Open a poke composer for a non-self client.
|
||||||
final ValueChanged<rust.BridgeClient>? onOpenClientPoke;
|
final ValueChanged<rust.BridgeClient>? onOpenClientPoke;
|
||||||
|
|
||||||
/// Open chat for a channel.
|
|
||||||
final ValueChanged<rust.BridgeChannel>? onOpenChannelChat;
|
|
||||||
|
|
||||||
/// Handle TeamSpeak server links embedded in server-provided text.
|
/// Handle TeamSpeak server links embedded in server-provided text.
|
||||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||||
|
|
||||||
@@ -248,64 +240,47 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _ChannelContextMenu(
|
return InkWell(
|
||||||
channel: channel,
|
onTap: onTap,
|
||||||
onChat: widget.onOpenChannelChat != null
|
child: ConstrainedBox(
|
||||||
? () => widget.onOpenChannelChat!(channel)
|
constraints: const BoxConstraints(minHeight: 40),
|
||||||
: null,
|
child: Row(
|
||||||
child: InkWell(
|
children: [
|
||||||
onTap: onTap,
|
SizedBox(width: channelIndent),
|
||||||
child: ConstrainedBox(
|
_expandButton(
|
||||||
constraints: const BoxConstraints(minHeight: 40),
|
theme,
|
||||||
child: Row(
|
hasVisibleChildren: hasVisibleChildren,
|
||||||
children: [
|
expanded: expanded,
|
||||||
SizedBox(width: channelIndent),
|
onPressed: onToggleExpanded,
|
||||||
_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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
SizedBox(
|
||||||
|
width: _channelIconColumnWidth,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Icon(
|
||||||
|
Icons.tag,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: _channelTextGap),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
channel.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (channel.hasPassword) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1107,60 +1082,3 @@ class _ClientVolumeSheetState extends State<_ClientVolumeSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Context menu for channel tiles. Shows a "Chat" option on right-click or
|
|
||||||
/// long-press. Primary tap passes through to the child for voice join.
|
|
||||||
class _ChannelContextMenu extends StatelessWidget {
|
|
||||||
const _ChannelContextMenu({
|
|
||||||
required this.channel,
|
|
||||||
this.onChat,
|
|
||||||
required this.child,
|
|
||||||
});
|
|
||||||
|
|
||||||
final rust.BridgeChannel channel;
|
|
||||||
final VoidCallback? onChat;
|
|
||||||
final Widget child;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (onChat == null) return child;
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onSecondaryTapDown: (details) =>
|
|
||||||
_show(context, details.globalPosition),
|
|
||||||
onLongPressStart: (details) =>
|
|
||||||
_show(context, details.globalPosition),
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _show(BuildContext context, Offset globalPosition) {
|
|
||||||
final overlay =
|
|
||||||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
|
||||||
final position = RelativeRect.fromLTRB(
|
|
||||||
globalPosition.dx,
|
|
||||||
globalPosition.dy,
|
|
||||||
overlay.size.width - globalPosition.dx,
|
|
||||||
overlay.size.height - globalPosition.dy,
|
|
||||||
);
|
|
||||||
showMenu<String>(
|
|
||||||
context: context,
|
|
||||||
position: position,
|
|
||||||
items: [
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'chat',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.chat_bubble_outline, size: 18),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(AppL10n.of(context).chatAction),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).then((value) {
|
|
||||||
if (value == 'chat') onChat?.call();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ class VoiceBar extends StatelessWidget {
|
|||||||
required this.onConfigure,
|
required this.onConfigure,
|
||||||
required this.onPttHeldChanged,
|
required this.onPttHeldChanged,
|
||||||
this.talkPowerBlocked = false,
|
this.talkPowerBlocked = false,
|
||||||
this.inputLevel,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool inChannel;
|
final bool inChannel;
|
||||||
@@ -54,10 +53,6 @@ class VoiceBar extends StatelessWidget {
|
|||||||
/// level meter. Pass `null` to render an idle meter.
|
/// level meter. Pass `null` to render an idle meter.
|
||||||
final rust.BridgeAudioStats? audioStats;
|
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
|
/// PTT capability badge inputs — passed through to
|
||||||
/// [`PttCapabilityBadge`].
|
/// [`PttCapabilityBadge`].
|
||||||
final String pttLevel;
|
final String pttLevel;
|
||||||
@@ -201,7 +196,7 @@ class VoiceBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
// Row 4: level meter
|
// Row 4: level meter
|
||||||
VoiceLevelMeter(active: levelActive, level: inputLevel ?? stats?.inputLevel),
|
VoiceLevelMeter(active: levelActive),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
if (stats != null)
|
if (stats != null)
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,78 +1,28 @@
|
|||||||
import 'dart:math' show max;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Shared compact level meter used by voice surfaces.
|
/// Shared compact level meter used by voice surfaces.
|
||||||
///
|
class VoiceLevelMeter extends StatelessWidget {
|
||||||
/// When [level] is null (no stats available yet), falls back to [active]
|
const VoiceLevelMeter({super.key, required this.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;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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(
|
return Container(
|
||||||
height: 8,
|
height: 8,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: TweenAnimationBuilder<double>(
|
child: FractionallySizedBox(
|
||||||
tween: Tween<double>(begin: begin, end: fill),
|
alignment: AlignmentDirectional.centerStart,
|
||||||
duration: const Duration(milliseconds: 120),
|
widthFactor: active ? 0.75 : 0.05,
|
||||||
curve: Curves.easeOut,
|
|
||||||
builder: (context, animatedFill, child) {
|
|
||||||
return FractionallySizedBox(
|
|
||||||
alignment: AlignmentDirectional.centerStart,
|
|
||||||
widthFactor: max(animatedFill, 0.02),
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: active
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.outlineVariant,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,2 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||||
|
|
||||||
// Mirror Flutter-Release.xcconfig (see explanation there).
|
|
||||||
OTHER_LDFLAGS = $(inherited) -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
|
|
||||||
STRIP_STYLE = non-global
|
|
||||||
|
|||||||
@@ -1,11 +1,2 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
#include "ephemeral/Flutter-Generated.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
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Versions/Current/Resources
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<?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 +1 @@
|
|||||||
|
A
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Versions/Current/chanora_bridge
|
||||||
@@ -1,20 +1,57 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- audio_session (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- chanora_bridge (1.0.0)
|
- chanora_bridge (1.0.0)
|
||||||
|
- connectivity_plus (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- FlutterMacOS (1.0.0)
|
- 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:
|
DEPENDENCIES:
|
||||||
|
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
||||||
- chanora_bridge (from `/Users/edison/dev/chanora/apps/chanora_flutter/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`)
|
- 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:
|
EXTERNAL SOURCES:
|
||||||
|
audio_session:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
||||||
chanora_bridge:
|
chanora_bridge:
|
||||||
:path: "/Users/edison/dev/chanora/apps/chanora_flutter/macos"
|
:path: "/Users/edison/dev/chanora/apps/chanora_flutter/macos"
|
||||||
|
connectivity_plus:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
|
||||||
FlutterMacOS:
|
FlutterMacOS:
|
||||||
:path: Flutter/ephemeral
|
: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:
|
SPEC CHECKSUMS:
|
||||||
chanora_bridge: 9d1469952801a1caa3bb56d5d3bce91df8dca4ad
|
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
|
||||||
|
chanora_bridge: 4105993843b5421ee4ce72220a74c63f6fd99103
|
||||||
|
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
|
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||||
|
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
|
||||||
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
|
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||||
|
|
||||||
PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb
|
PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
45F255D1DE0134185DB5423D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 06E1AA7E1FB968C1D78DA8DE /* PrivacyInfo.xcprivacy */; };
|
45F255D1DE0134185DB5423D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 06E1AA7E1FB968C1D78DA8DE /* PrivacyInfo.xcprivacy */; };
|
||||||
9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */; };
|
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 */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -94,7 +93,6 @@
|
|||||||
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>"; };
|
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; };
|
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; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -110,7 +108,6 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
|
||||||
8C6000042DD0000000000001 /* SileroCoreML in Frameworks */,
|
8C6000042DD0000000000001 /* SileroCoreML in Frameworks */,
|
||||||
9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */,
|
9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
@@ -173,7 +170,6 @@
|
|||||||
33CEB47122A05771004F2AC0 /* Flutter */ = {
|
33CEB47122A05771004F2AC0 /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
|
||||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
|
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
|
||||||
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
|
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
|
||||||
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
|
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
|
||||||
@@ -248,7 +244,6 @@
|
|||||||
4287874B577AE59BBE39386D /* [CP] Check Pods Manifest.lock */,
|
4287874B577AE59BBE39386D /* [CP] Check Pods Manifest.lock */,
|
||||||
33CC10E92044A3C60003C045 /* Sources */,
|
33CC10E92044A3C60003C045 /* Sources */,
|
||||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||||
CA110002000000000000A200 /* Verify Silero Exports */,
|
|
||||||
33CC10EB2044A3C60003C045 /* Resources */,
|
33CC10EB2044A3C60003C045 /* Resources */,
|
||||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||||
@@ -261,7 +256,6 @@
|
|||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
|
||||||
8C6000032DD0000000000001 /* SileroCoreML */,
|
8C6000032DD0000000000001 /* SileroCoreML */,
|
||||||
);
|
);
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
@@ -308,7 +302,6 @@
|
|||||||
);
|
);
|
||||||
mainGroup = 33CC10E42044A3C60003C045;
|
mainGroup = 33CC10E42044A3C60003C045;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
|
||||||
8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
|
8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
||||||
@@ -442,21 +435,6 @@
|
|||||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
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;
|
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 */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -887,10 +865,6 @@
|
|||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = ../../../silero-coreml;
|
relativePath = ../../../silero-coreml;
|
||||||
};
|
};
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
|
||||||
isa = XCLocalSwiftPackageReference;
|
|
||||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
|
||||||
};
|
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
@@ -899,10 +873,6 @@
|
|||||||
package = 8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
|
package = 8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
|
||||||
productName = SileroCoreML;
|
productName = SileroCoreML;
|
||||||
};
|
};
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = FlutterGeneratedPluginSwiftPackage;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
|
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
|
||||||
|
|||||||
@@ -5,24 +5,6 @@
|
|||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "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>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ class AppDelegate: FlutterAppDelegate {
|
|||||||
override func applicationDidFinishLaunching(_ notification: Notification) {
|
override func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
super.applicationDidFinishLaunching(notification)
|
super.applicationDidFinishLaunching(notification)
|
||||||
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
|
||||||
ChanoraSileroSelfTest.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ask for microphone access on launch rather than on first
|
// Ask for microphone access on launch rather than on first
|
||||||
// voice-channel join. Matches user expectations for a voice
|
// voice-channel join. Matches user expectations for a voice
|
||||||
// chat client and saves the user from a surprising prompt
|
// chat client and saves the user from a surprising prompt
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- Flutter default: app-sandbox + JIT for the Dart VM. network.server is
|
<!-- Flutter default: app-sandbox + JIT for the Dart VM + server
|
||||||
required for the `flutter run` hot-reload listener AND, on macOS, for
|
listener for `flutter run` hot-reload. -->
|
||||||
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>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
|||||||
@@ -22,8 +22,6 @@
|
|||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
|
||||||
<true/>
|
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
import Cocoa
|
|
||||||
import CoreAudio
|
|
||||||
import FlutterMacOS
|
|
||||||
import os.log
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// MacOSAudioLifecycle
|
|
||||||
//
|
|
||||||
// Native-side MethodChannel handler for macOS audio lifecycle events.
|
|
||||||
// Closes the iOS / macOS asymmetry that the iOS AppDelegate handles via
|
|
||||||
// AVAudioSession (no AVAudioSession equivalent on macOS). The macOS
|
|
||||||
// equivalents of the iOS lifecycle events are:
|
|
||||||
//
|
|
||||||
// * Default audio device change — Core Audio HAL default-input /
|
|
||||||
// default-output device property listeners (mirrors iOS route
|
|
||||||
// change). Posted as `handleDefaultDeviceChange` with payload
|
|
||||||
// `{role: 'input' | 'output'}`.
|
|
||||||
//
|
|
||||||
// * VPIO AudioUnit configuration change — observed via the VPIO
|
|
||||||
// unit's `kAudioUnitProperty_StreamFormat` property listener.
|
|
||||||
// Posted as `handleConfigurationChange` with no payload.
|
|
||||||
//
|
|
||||||
// Channel name: `chanora/macos_audio_lifecycle`. The Dart side wires the
|
|
||||||
// matching `chanora/ios_audio_lifecycle`-shaped event surface in
|
|
||||||
// `audio_lifecycle_service.dart` and currently logs the events; the
|
|
||||||
// engine-restart call (FRB `macos_default_device_changed`) is a
|
|
||||||
// follow-up.
|
|
||||||
//
|
|
||||||
// Trace: SysRS-051 (macOS audio-lifecycle asymmetry), SysRS-311
|
|
||||||
// (the deferred macOS audio-lifecycle platform-adapter allocation).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private let kLogTag = "chanora_flutter.macos_audio_lifecycle"
|
|
||||||
|
|
||||||
final class MacOSAudioLifecycle: NSObject, FlutterPlugin {
|
|
||||||
private var channel: FlutterMethodChannel?
|
|
||||||
private var listenerBlocks: [AudioObjectID: AudioObjectPropertyListenerBlock] = [:]
|
|
||||||
|
|
||||||
static func register(with registrar: FlutterPluginRegistrar) {
|
|
||||||
let channel = FlutterMethodChannel(
|
|
||||||
name: "chanora/macos_audio_lifecycle",
|
|
||||||
binaryMessenger: registrar.messenger
|
|
||||||
)
|
|
||||||
let instance = MacOSAudioLifecycle()
|
|
||||||
instance.channel = channel
|
|
||||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
|
||||||
instance.startObserving()
|
|
||||||
}
|
|
||||||
|
|
||||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
||||||
// macOS audio lifecycle is a one-way push: Swift → Dart.
|
|
||||||
// The Dart side never invokes methods on this channel.
|
|
||||||
result(FlutterMethodNotImplemented)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Core Audio HAL listeners
|
|
||||||
|
|
||||||
private func startObserving() {
|
|
||||||
registerDefaultDeviceListener(role: "output")
|
|
||||||
registerDefaultDeviceListener(role: "input")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func registerDefaultDeviceListener(role: String) {
|
|
||||||
let selector: AudioObjectPropertySelector = (role == "input")
|
|
||||||
? kAudioHardwarePropertyDefaultInputDevice
|
|
||||||
: kAudioHardwarePropertyDefaultOutputDevice
|
|
||||||
var address = AudioObjectPropertyAddress(
|
|
||||||
mSelector: selector,
|
|
||||||
mScope: kAudioObjectPropertyScopeGlobal,
|
|
||||||
mElement: kAudioObjectPropertyElementMain
|
|
||||||
)
|
|
||||||
let objectID: AudioObjectID = AudioObjectID(kAudioObjectSystemObject)
|
|
||||||
let channel = self.channel
|
|
||||||
let block: AudioObjectPropertyListenerBlock = { _, _ in
|
|
||||||
os_log("default %{public}@ device changed", log: OSLog(subsystem: kLogTag, category: "lifecycle"), type: .info, role)
|
|
||||||
channel?.invokeMethod("handleDefaultDeviceChange", arguments: ["role": role])
|
|
||||||
}
|
|
||||||
let status = AudioObjectAddPropertyListener(objectID, &address, block, nil)
|
|
||||||
if status == noErr {
|
|
||||||
listenerBlocks[objectID + UInt32(role.hashValue & 0xFFFF)] = block
|
|
||||||
os_log("registered default %{public}@ device listener", log: OSLog(subsystem: kLogTag, category: "lifecycle"), type: .info, role)
|
|
||||||
} else {
|
|
||||||
os_log("failed to register default %{public}@ device listener (OSStatus %{public}d)",
|
|
||||||
log: OSLog(subsystem: kLogTag, category: "lifecycle"),
|
|
||||||
type: .error, role, Int(status))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Best-effort cleanup; AudioObjectRemovePropertyListener only
|
|
||||||
// matters if the system still holds the block.
|
|
||||||
for (id, block) in listenerBlocks {
|
|
||||||
for selector in [
|
|
||||||
kAudioHardwarePropertyDefaultInputDevice,
|
|
||||||
kAudioHardwarePropertyDefaultOutputDevice,
|
|
||||||
] {
|
|
||||||
var address = AudioObjectPropertyAddress(
|
|
||||||
mSelector: selector,
|
|
||||||
mScope: kAudioObjectPropertyScopeGlobal,
|
|
||||||
mElement: kAudioObjectPropertyElementMain
|
|
||||||
)
|
|
||||||
_ = AudioObjectRemovePropertyListener(id, &address, block, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -48,7 +48,6 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
|||||||
// -- Input Monitoring ---------------------------------------------------
|
// -- Input Monitoring ---------------------------------------------------
|
||||||
case "checkInputMonitoring":
|
case "checkInputMonitoring":
|
||||||
result(inputMonitoringStateString())
|
result(inputMonitoringStateString())
|
||||||
startInputMonitoringPolling()
|
|
||||||
|
|
||||||
case "requestInputMonitoring":
|
case "requestInputMonitoring":
|
||||||
requestInputMonitoring(result: result)
|
requestInputMonitoring(result: result)
|
||||||
@@ -63,18 +62,6 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
|||||||
case "triggerLocalNetworkPrompt":
|
case "triggerLocalNetworkPrompt":
|
||||||
triggerLocalNetworkPrompt(result: result)
|
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 ------------------------------------------------------
|
// -- Notifications ------------------------------------------------------
|
||||||
case "checkNotifications":
|
case "checkNotifications":
|
||||||
checkNotifications(result: result)
|
checkNotifications(result: result)
|
||||||
@@ -211,10 +198,10 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
|||||||
case .failed(let error):
|
case .failed(let error):
|
||||||
if !resolved {
|
if !resolved {
|
||||||
resolved = true
|
resolved = true
|
||||||
// Check for DNS policy-denied error (kDNSServiceErr_PolicyDenied = -65570).
|
let code = error.errorCode
|
||||||
// This is the canonical signal that the user denied the Local Network prompt.
|
// POSIX permission-denied or network-down signals that
|
||||||
if case .dns(let dnsError) = error,
|
// the user denied the Local Network prompt.
|
||||||
dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) {
|
if code == ENETDOWN || code == EACCES || code == EPERM {
|
||||||
result("Denied")
|
result("Denied")
|
||||||
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
|
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
|
||||||
"state": "Denied",
|
"state": "Denied",
|
||||||
@@ -226,23 +213,11 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
browser.cancel()
|
browser.cancel()
|
||||||
case .waiting(let error):
|
case .waiting:
|
||||||
// The browser is waiting for network. If the specific DNS error
|
// The browser is waiting for network — this is normal and
|
||||||
// is kDNSServiceErr_PolicyDenied, the user explicitly denied
|
// may mean the permission dialog is showing. Don't resolve
|
||||||
// the Local Network prompt — report immediately.
|
// yet; wait for .ready or .failed or the timeout.
|
||||||
if case .dns(let dnsError) = error,
|
break
|
||||||
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:
|
case .setup, .cancelled:
|
||||||
break
|
break
|
||||||
@unknown default:
|
@unknown default:
|
||||||
@@ -271,77 +246,6 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe whether Local Network access is currently denied for a specific
|
|
||||||
/// host:port by creating a short-lived NWConnection and checking
|
|
||||||
/// `unsatisfiedReason == .localNetworkDenied`. This does NOT trigger a
|
|
||||||
/// new system prompt — it is a read-only check.
|
|
||||||
private func checkLocalNetworkAccess(
|
|
||||||
host: String, port: Int, result: @escaping FlutterResult
|
|
||||||
) {
|
|
||||||
if #available(macOS 15.0, *) {
|
|
||||||
checkLocalNetworkAccessImpl(host: host, port: port, result: result)
|
|
||||||
} else {
|
|
||||||
result("Unsupported")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 15.0, *)
|
|
||||||
private func checkLocalNetworkAccessImpl(
|
|
||||||
host: String, port: Int, result: @escaping FlutterResult
|
|
||||||
) {
|
|
||||||
guard let endpointPort = NWEndpoint.Port(rawValue: UInt16(port)) else {
|
|
||||||
result("NotDetermined")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let endpointHost = NWEndpoint.Host(host)
|
|
||||||
let connection = NWConnection(
|
|
||||||
host: endpointHost, port: endpointPort, using: .tcp)
|
|
||||||
let queue = DispatchQueue(
|
|
||||||
label: "app.chanora.macos_permissions.local_network_check")
|
|
||||||
|
|
||||||
var didComplete = false
|
|
||||||
|
|
||||||
func finish(_ state: String) {
|
|
||||||
guard !didComplete else { return }
|
|
||||||
didComplete = true
|
|
||||||
connection.cancel()
|
|
||||||
result(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.stateUpdateHandler = { state in
|
|
||||||
switch state {
|
|
||||||
case .waiting, .failed:
|
|
||||||
if connection.currentPath?.unsatisfiedReason
|
|
||||||
== .localNetworkDenied {
|
|
||||||
finish("Denied")
|
|
||||||
} else {
|
|
||||||
finish("NotDetermined")
|
|
||||||
}
|
|
||||||
case .ready:
|
|
||||||
finish("Granted")
|
|
||||||
case .cancelled:
|
|
||||||
finish("NotDetermined")
|
|
||||||
case .setup, .preparing:
|
|
||||||
break
|
|
||||||
@unknown default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.start(queue: queue)
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
||||||
if !didComplete {
|
|
||||||
if connection.currentPath?.unsatisfiedReason
|
|
||||||
== .localNetworkDenied {
|
|
||||||
finish("Denied")
|
|
||||||
} else {
|
|
||||||
finish("NotDetermined")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Notifications
|
// Notifications
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -399,8 +303,6 @@ class MainFlutterWindow: NSWindow {
|
|||||||
|
|
||||||
// Register the macOS permissions MethodChannel handler.
|
// Register the macOS permissions MethodChannel handler.
|
||||||
MacOSPermissionsHandler.register(with: flutterViewController.registrar(forPlugin: "MacOSPermissionsHandler"))
|
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()
|
super.awakeFromNib()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,12 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- Release builds omit cs.allow-jit (Flutter hot-reload only). They keep
|
<!-- Release builds omit the cs.allow-jit + network.server entitlements
|
||||||
network.server because the macOS App Sandbox classifies UDP bind()
|
used only by Flutter's debug hot-reload. -->
|
||||||
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>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.device.audio-input</key>
|
<key>com.apple.security.device.audio-input</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import CoreML
|
import CoreML
|
||||||
import Darwin
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SileroCoreML
|
import SileroCoreML
|
||||||
|
|
||||||
@@ -97,136 +96,3 @@ public func chanoraSileroVadFreeString(_ string: UnsafeMutablePointer<CChar>?) {
|
|||||||
guard let string else { return }
|
guard let string else { return }
|
||||||
free(string)
|
free(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public final class ChanoraSileroSelfTest: NSObject {
|
|
||||||
// Validates the same code path the Rust framework uses: dlsym(RTLD_DEFAULT) for all
|
|
||||||
// six @_cdecl symbols, then exercises create -> reset -> process -> destroy. Catches
|
|
||||||
// the dead-strip / linker-export class of bug that broke TestFlight; calling the Swift
|
|
||||||
// functions directly would mask it because direct calls bypass the dynamic symbol table.
|
|
||||||
@objc public static func run() {
|
|
||||||
let started = DispatchTime.now()
|
|
||||||
|
|
||||||
// Static linker references: keep the Swift compiler / linker from
|
|
||||||
// dead-stripping the @_cdecl symbols under Whole-Module-Optimization
|
|
||||||
// + LTO in Archive builds. dlsym(RTLD_DEFAULT) below does NOT count
|
|
||||||
// as a static reference for the dead-stripper — these `_ = ` lines
|
|
||||||
// do. Without them, TestFlight builds shipped without the symbols
|
|
||||||
// even though Debug builds (no LTO) worked.
|
|
||||||
//
|
|
||||||
// The `withoutActuallyEscaping` dance prevents the optimizer from
|
|
||||||
// proving the references are unused: assigning the function value
|
|
||||||
// to a `@convention(c)` typealias forces address-taken semantics.
|
|
||||||
_ = unsafeBitCast(
|
|
||||||
chanoraSileroVadCreate as @convention(c) () -> UnsafeMutableRawPointer?,
|
|
||||||
to: UnsafeRawPointer.self,
|
|
||||||
)
|
|
||||||
_ = unsafeBitCast(
|
|
||||||
chanoraSileroVadDestroy as @convention(c) (UnsafeMutableRawPointer?) -> Void,
|
|
||||||
to: UnsafeRawPointer.self,
|
|
||||||
)
|
|
||||||
_ = unsafeBitCast(
|
|
||||||
chanoraSileroVadReset as @convention(c) (UnsafeMutableRawPointer?) -> Int32,
|
|
||||||
to: UnsafeRawPointer.self,
|
|
||||||
)
|
|
||||||
_ = unsafeBitCast(
|
|
||||||
chanoraSileroVadProcess
|
|
||||||
as @convention(c) (
|
|
||||||
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int,
|
|
||||||
UnsafeMutablePointer<Float>?
|
|
||||||
) -> Int32,
|
|
||||||
to: UnsafeRawPointer.self,
|
|
||||||
)
|
|
||||||
_ = unsafeBitCast(
|
|
||||||
chanoraSileroVadLastError as @convention(c) () -> UnsafeMutablePointer<CChar>?,
|
|
||||||
to: UnsafeRawPointer.self,
|
|
||||||
)
|
|
||||||
_ = unsafeBitCast(
|
|
||||||
chanoraSileroVadFreeString as @convention(c) (UnsafeMutablePointer<CChar>?) -> Void,
|
|
||||||
to: UnsafeRawPointer.self,
|
|
||||||
)
|
|
||||||
|
|
||||||
typealias CreateFn = @convention(c) () -> UnsafeMutableRawPointer?
|
|
||||||
typealias DestroyFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
|
|
||||||
typealias ResetFn = @convention(c) (UnsafeMutableRawPointer?) -> Int32
|
|
||||||
typealias ProcessFn = @convention(c) (
|
|
||||||
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int, UnsafeMutablePointer<Float>?
|
|
||||||
) -> Int32
|
|
||||||
typealias LastErrorFn = @convention(c) () -> UnsafeMutablePointer<CChar>?
|
|
||||||
typealias FreeStringFn = @convention(c) (UnsafeMutablePointer<CChar>?) -> Void
|
|
||||||
|
|
||||||
func resolve<T>(_ name: String, as type: T.Type) -> T? {
|
|
||||||
guard let raw = dlsym(UnsafeMutableRawPointer(bitPattern: -2), name) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return unsafeBitCast(raw, to: type)
|
|
||||||
}
|
|
||||||
|
|
||||||
let names = [
|
|
||||||
"chanora_silero_vad_create",
|
|
||||||
"chanora_silero_vad_destroy",
|
|
||||||
"chanora_silero_vad_reset",
|
|
||||||
"chanora_silero_vad_process",
|
|
||||||
"chanora_silero_vad_last_error",
|
|
||||||
"chanora_silero_vad_free_string",
|
|
||||||
]
|
|
||||||
let missing = names.filter { dlsym(UnsafeMutableRawPointer(bitPattern: -2), $0) == nil }
|
|
||||||
if !missing.isEmpty {
|
|
||||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED dlsym missing=\(missing.joined(separator: ","))")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard
|
|
||||||
let create = resolve("chanora_silero_vad_create", as: CreateFn.self),
|
|
||||||
let destroy = resolve("chanora_silero_vad_destroy", as: DestroyFn.self),
|
|
||||||
let reset = resolve("chanora_silero_vad_reset", as: ResetFn.self),
|
|
||||||
let process = resolve("chanora_silero_vad_process", as: ProcessFn.self),
|
|
||||||
let lastError = resolve("chanora_silero_vad_last_error", as: LastErrorFn.self),
|
|
||||||
let freeString = resolve("chanora_silero_vad_free_string", as: FreeStringFn.self)
|
|
||||||
else {
|
|
||||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED unsafeBitCast resolution")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func readError() -> String {
|
|
||||||
guard let ptr = lastError() else { return "unknown" }
|
|
||||||
let msg = String(cString: ptr)
|
|
||||||
freeString(ptr)
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let handle = create() else {
|
|
||||||
let elapsedMs = elapsedMs(since: started)
|
|
||||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED at create err=\(readError()) elapsed_ms=\(elapsedMs)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let resetRc = reset(handle)
|
|
||||||
if resetRc != 0 {
|
|
||||||
destroy(handle)
|
|
||||||
let elapsedMs = elapsedMs(since: started)
|
|
||||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED at reset rc=\(resetRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let chunkSize = SileroVADRunner.chunkSize
|
|
||||||
var probability: Float = 0
|
|
||||||
let samples = [Float](repeating: 0, count: chunkSize)
|
|
||||||
let processRc = samples.withUnsafeBufferPointer { buf -> Int32 in
|
|
||||||
process(handle, buf.baseAddress, chunkSize, &probability)
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy(handle)
|
|
||||||
|
|
||||||
let elapsedMs = elapsedMs(since: started)
|
|
||||||
if processRc == 0 {
|
|
||||||
NSLog("chanora_flutter: SileroCoreML self-test OK probability=\(probability) elapsed_ms=\(elapsedMs)")
|
|
||||||
} else {
|
|
||||||
NSLog("chanora_flutter: SileroCoreML self-test FAILED at process rc=\(processRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func elapsedMs(since start: DispatchTime) -> String {
|
|
||||||
let ns = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds
|
|
||||||
return String(format: "%.1f", Double(ns) / 1_000_000.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -52,25 +52,19 @@ Pod::Spec.new do |s|
|
|||||||
|
|
||||||
echo "[chanora_bridge.podspec] cargo build aarch64-apple-darwin"
|
echo "[chanora_bridge.podspec] cargo build aarch64-apple-darwin"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \\
|
PATH="$HOME/.cargo/bin:$PATH" \\
|
||||||
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\
|
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\
|
||||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||||
LIBOPUS_STATIC=1 \\
|
LIBOPUS_STATIC=1 \\
|
||||||
LIBOPUS_NO_PKG=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
|
cargo build --release --target aarch64-apple-darwin -p chanora_bridge
|
||||||
|
|
||||||
echo "[chanora_bridge.podspec] cargo build x86_64-apple-darwin"
|
echo "[chanora_bridge.podspec] cargo build x86_64-apple-darwin"
|
||||||
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \\
|
PATH="$HOME/.cargo/bin:$PATH" \\
|
||||||
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\
|
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\
|
||||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||||
LIBOPUS_STATIC=1 \\
|
LIBOPUS_STATIC=1 \\
|
||||||
LIBOPUS_NO_PKG=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
|
cargo build --release --target x86_64-apple-darwin -p chanora_bridge
|
||||||
|
|
||||||
if [ ! -f "$BRIDGE_ARM64" ]; then
|
if [ ! -f "$BRIDGE_ARM64" ]; then
|
||||||
@@ -118,19 +112,7 @@ PLIST
|
|||||||
|
|
||||||
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \\
|
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \\
|
||||||
"$FW/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
|
SCRIPT
|
||||||
|
|
||||||
# Pod CocoaPods picks this up; the framework gets embedded into
|
# Pod CocoaPods picks this up; the framework gets embedded into
|
||||||
@@ -159,54 +141,44 @@ PLIST
|
|||||||
|
|
||||||
echo "[chanora_bridge script_phase] cargo build aarch64-apple-darwin"
|
echo "[chanora_bridge script_phase] cargo build aarch64-apple-darwin"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \
|
PATH="$HOME/.cargo/bin:$PATH" \
|
||||||
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \
|
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \
|
||||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
CMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||||
LIBOPUS_STATIC=1 \
|
LIBOPUS_STATIC=1 \
|
||||||
LIBOPUS_NO_PKG=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
|
cargo build --release --target aarch64-apple-darwin -p chanora_bridge
|
||||||
|
|
||||||
echo "[chanora_bridge script_phase] cargo build x86_64-apple-darwin"
|
echo "[chanora_bridge script_phase] cargo build x86_64-apple-darwin"
|
||||||
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \
|
PATH="$HOME/.cargo/bin:$PATH" \
|
||||||
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \
|
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \
|
||||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
CMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||||
LIBOPUS_STATIC=1 \
|
LIBOPUS_STATIC=1 \
|
||||||
LIBOPUS_NO_PKG=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
|
cargo build --release --target x86_64-apple-darwin -p chanora_bridge
|
||||||
|
|
||||||
cd "$REPO_ROOT/apps/chanora_flutter/macos"
|
cd "$REPO_ROOT/apps/chanora_flutter/macos"
|
||||||
FW=Frameworks/chanora_bridge.framework
|
FW=Frameworks/chanora_bridge.framework
|
||||||
FW_UP_TO_DATE=0
|
|
||||||
|
|
||||||
# Skip the wrap step if the framework's binary is already
|
# Skip the wrap step if the framework's binary is already
|
||||||
# up-to-date with the cargo output (fast no-op on incremental
|
# up-to-date with the cargo output (fast no-op on incremental
|
||||||
# builds where Rust didn't change). We still publish the dSYM
|
# builds where Rust didn't change).
|
||||||
# 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
|
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"
|
echo "[chanora_bridge script_phase] framework already up-to-date"
|
||||||
FW_UP_TO_DATE=1
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$FW_UP_TO_DATE" = 0 ]; then
|
# Create universal binary with lipo.
|
||||||
# Create universal binary with lipo.
|
mkdir -p "$(dirname "$UNIVERSAL")"
|
||||||
mkdir -p "$(dirname "$UNIVERSAL")"
|
lipo -create "$BRIDGE_ARM64" "$BRIDGE_X86_64" -output "$UNIVERSAL"
|
||||||
lipo -create "$BRIDGE_ARM64" "$BRIDGE_X86_64" -output "$UNIVERSAL"
|
|
||||||
|
|
||||||
rm -rf "$FW"
|
rm -rf "$FW"
|
||||||
mkdir -p "$FW/Versions/A/Resources"
|
mkdir -p "$FW/Versions/A/Resources"
|
||||||
ln -sfh A "$FW/Versions/Current"
|
ln -sfh A "$FW/Versions/Current"
|
||||||
ln -sfh Versions/Current/Resources "$FW/Resources"
|
ln -sfh Versions/Current/Resources "$FW/Resources"
|
||||||
cp "$UNIVERSAL" "$FW/Versions/A/chanora_bridge"
|
cp "$UNIVERSAL" "$FW/Versions/A/chanora_bridge"
|
||||||
ln -sfh Versions/Current/chanora_bridge "$FW/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"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
@@ -223,24 +195,9 @@ PLIST
|
|||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
|
|
||||||
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \
|
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \
|
||||||
"$FW/Versions/A/chanora_bridge"
|
"$FW/Versions/A/chanora_bridge"
|
||||||
rm -rf "$FW.dSYM"
|
echo "[chanora_bridge script_phase] framework refreshed"
|
||||||
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
|
SCRIPT
|
||||||
:execution_position => :before_compile,
|
:execution_position => :before_compile,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,10 +457,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -790,10 +790,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.10"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# verify_silero_exports.sh
|
|
||||||
#
|
|
||||||
# Asserts that all six chanora_silero_vad_* C symbols that the Rust
|
|
||||||
# chanora_bridge framework resolves via dlsym(RTLD_DEFAULT) are present
|
|
||||||
# in the linked app binary's dynamic export table — verified per
|
|
||||||
# architecture slice for macOS universal builds, because `nm` on a
|
|
||||||
# universal Mach-O without -arch will succeed if a symbol exists in ANY
|
|
||||||
# slice, not every slice. A missing symbol in just the x86_64 slice
|
|
||||||
# would silently break Intel Macs.
|
|
||||||
#
|
|
||||||
# Without this check, Xcode Archive's -dead_strip can remove these
|
|
||||||
# Swift @_cdecl symbols (no Swift caller exists) and CoreML VAD falls
|
|
||||||
# back to WebRTC on TestFlight/App Store with no compile-time,
|
|
||||||
# link-time, or runtime warning. We hit that bug once; this script
|
|
||||||
# ensures we never ship it again.
|
|
||||||
#
|
|
||||||
# Runs as an Xcode build phase after Link Binary, on iOS and macOS.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -z "${TARGET_BUILD_DIR}" ] || [ -z "${EXECUTABLE_PATH}" ]; then
|
|
||||||
echo "error: verify_silero_exports.sh requires TARGET_BUILD_DIR and EXECUTABLE_PATH (run from Xcode build phase)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
BINARY="${TARGET_BUILD_DIR}/${EXECUTABLE_PATH}"
|
|
||||||
|
|
||||||
if [ ! -f "${BINARY}" ]; then
|
|
||||||
echo "error: app binary not found at ${BINARY}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
REQUIRED_SYMBOLS="
|
|
||||||
_chanora_silero_vad_create
|
|
||||||
_chanora_silero_vad_destroy
|
|
||||||
_chanora_silero_vad_reset
|
|
||||||
_chanora_silero_vad_process
|
|
||||||
_chanora_silero_vad_last_error
|
|
||||||
_chanora_silero_vad_free_string
|
|
||||||
"
|
|
||||||
|
|
||||||
# Enumerate slices. `lipo -archs` prints arches space-separated for
|
|
||||||
# universal Mach-O; for thin binaries it prints the single arch.
|
|
||||||
ARCHS=$(xcrun lipo -archs "${BINARY}" 2>/dev/null || echo "")
|
|
||||||
if [ -z "${ARCHS}" ]; then
|
|
||||||
echo "error: xcrun lipo -archs failed for ${BINARY}; cannot enumerate slices" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
FAILED=0
|
|
||||||
for arch in ${ARCHS}; do
|
|
||||||
EXPORTED=$(xcrun nm -arch "${arch}" -gU "${BINARY}" 2>/dev/null | awk '{print $NF}')
|
|
||||||
if [ -z "${EXPORTED}" ]; then
|
|
||||||
echo "error: xcrun nm produced no output for arch=${arch} on ${BINARY}" >&2
|
|
||||||
FAILED=1
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
MISSING=""
|
|
||||||
for sym in ${REQUIRED_SYMBOLS}; do
|
|
||||||
if ! echo "${EXPORTED}" | grep -qx "${sym}"; then
|
|
||||||
MISSING="${MISSING} ${sym}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -n "${MISSING}" ]; then
|
|
||||||
echo "error: chanora_silero_vad exports missing from $(basename "${BINARY}") [arch=${arch}]:${MISSING}" >&2
|
|
||||||
FAILED=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "${FAILED}" -ne 0 ]; then
|
|
||||||
echo "error: Rust dlsym(RTLD_DEFAULT) resolution will fail and CoreML VAD will fall back to WebRTC." >&2
|
|
||||||
echo "error: Check OTHER_LDFLAGS -exported_symbol entries in Flutter/*.xcconfig and the @_cdecl exports in Runner/SileroCoreMLBridge.swift." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "verify_silero_exports: all 6 chanora_silero_vad_* symbols present in $(basename "${BINARY}") [arches: ${ARCHS}]"
|
|
||||||
@@ -4,7 +4,7 @@ import 'package:chanora_flutter/services/audio_lifecycle_service.dart';
|
|||||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('parseBridgeAudioRoute maps iOS-classified route strings', () {
|
test('parseBridgeAudioRoute maps platform route names', () {
|
||||||
expect(parseBridgeAudioRoute('Earpiece'), rust.BridgeAudioRoute.earpiece);
|
expect(parseBridgeAudioRoute('Earpiece'), rust.BridgeAudioRoute.earpiece);
|
||||||
expect(parseBridgeAudioRoute('Speaker'), rust.BridgeAudioRoute.speaker);
|
expect(parseBridgeAudioRoute('Speaker'), rust.BridgeAudioRoute.speaker);
|
||||||
expect(
|
expect(
|
||||||
@@ -22,34 +22,4 @@ void main() {
|
|||||||
expect(parseBridgeAudioRoute('Unknown'), rust.BridgeAudioRoute.unknown);
|
expect(parseBridgeAudioRoute('Unknown'), rust.BridgeAudioRoute.unknown);
|
||||||
expect(parseBridgeAudioRoute('Other'), rust.BridgeAudioRoute.unknown);
|
expect(parseBridgeAudioRoute('Other'), rust.BridgeAudioRoute.unknown);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseBridgeAudioRoute maps Android UsbHeadset to wiredHeadset', () {
|
|
||||||
// AndroidAudioLifecycleController.classifyDevice emits 'UsbHeadset' for
|
|
||||||
// AudioDeviceInfo.TYPE_USB_HEADSET. USB audio is functionally a
|
|
||||||
// wired-class device — the Kotlin classifier's own preference ordering
|
|
||||||
// (line 176 of AndroidAudioLifecycleController.kt) groups it with
|
|
||||||
// WiredHeadset/BluetoothHfp/BluetoothA2dp.
|
|
||||||
expect(parseBridgeAudioRoute('UsbHeadset'),
|
|
||||||
rust.BridgeAudioRoute.wiredHeadset);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseBridgeAudioRoute maps Android Hdmi to unknown', () {
|
|
||||||
// HDMI is a display-out transport, not a voice-call audio path; no
|
|
||||||
// existing BridgeAudioRoute variant fits. Treat as unknown rather
|
|
||||||
// than misclassify as Speaker.
|
|
||||||
expect(parseBridgeAudioRoute('Hdmi'), rust.BridgeAudioRoute.unknown);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('wireMacosAudioLifecycle is a no-op on non-macOS and registers on macOS', () {
|
|
||||||
// MethodChannel needs a binary messenger, which requires the test
|
|
||||||
// binding to be initialised first.
|
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
// Channel name constant matches the Swift side (MacOSAudioLifecycle.swift).
|
|
||||||
expect(macosAudioLifecycleChannelName, 'chanora/macos_audio_lifecycle');
|
|
||||||
// The wire is a no-op on the test platform (CI defaults to host OS
|
|
||||||
// which may be macOS or Linux). On Linux it returns early; on macOS
|
|
||||||
// it installs a handler. Either way, it must not throw.
|
|
||||||
expect(() => wireMacosAudioLifecycle(), returnsNormally);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:chanora_flutter/services/hard_mute_owners.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('HardMuteOwners', () {
|
|
||||||
test('manual mute survives talk-power block and restore', () {
|
|
||||||
const owners = HardMuteOwners(manual: true);
|
|
||||||
|
|
||||||
final blocked = owners.copyWith(talkPower: true);
|
|
||||||
expect(blocked.effective, isTrue);
|
|
||||||
|
|
||||||
final restored = blocked.copyWith(talkPower: false);
|
|
||||||
expect(restored.manual, isTrue);
|
|
||||||
expect(restored.talkPower, isFalse);
|
|
||||||
expect(restored.effective, isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('effective mute is the union of independent owners', () {
|
|
||||||
expect(const HardMuteOwners().effective, isFalse);
|
|
||||||
expect(const HardMuteOwners(manual: true).effective, isTrue);
|
|
||||||
expect(const HardMuteOwners(permission: true).effective, isTrue);
|
|
||||||
expect(const HardMuteOwners(talkPower: true).effective, isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bridge mute does not convert talk-power owner into manual owner', () {
|
|
||||||
const owners = HardMuteOwners(talkPower: true);
|
|
||||||
|
|
||||||
final synced = owners.withBridgeManualMute(true);
|
|
||||||
|
|
||||||
expect(synced.manual, isFalse);
|
|
||||||
expect(synced.talkPower, isTrue);
|
|
||||||
expect(synced.effective, isTrue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:chanora_flutter/services/ios_audio_session_controller.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
group('IosAudioSessionController', () {
|
|
||||||
const channel = MethodChannel(iosAudioSessionChannelName);
|
|
||||||
final messenger = TestDefaultBinaryMessengerBinding
|
|
||||||
.instance.defaultBinaryMessenger;
|
|
||||||
|
|
||||||
tearDown(() {
|
|
||||||
messenger.setMockMethodCallHandler(channel, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('channel name matches Swift contract', () {
|
|
||||||
expect(iosAudioSessionChannelName, 'chanora/ios_audio_session');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('activate invokes activateVoiceSession on iOS', () async {
|
|
||||||
final calls = <MethodCall>[];
|
|
||||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
|
||||||
calls.add(call);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
final controller = IosAudioSessionController(
|
|
||||||
channel: channel,
|
|
||||||
isIos: true,
|
|
||||||
);
|
|
||||||
await controller.activate();
|
|
||||||
|
|
||||||
expect(calls.map((c) => c.method), ['activateVoiceSession']);
|
|
||||||
expect(calls.single.arguments, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deactivate invokes deactivateVoiceSession on iOS', () async {
|
|
||||||
final calls = <MethodCall>[];
|
|
||||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
|
||||||
calls.add(call);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
final controller = IosAudioSessionController(
|
|
||||||
channel: channel,
|
|
||||||
isIos: true,
|
|
||||||
);
|
|
||||||
await controller.deactivate();
|
|
||||||
|
|
||||||
expect(calls.map((c) => c.method), ['deactivateVoiceSession']);
|
|
||||||
expect(calls.single.arguments, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('activate is a no-op on non-iOS platforms', () async {
|
|
||||||
var invoked = false;
|
|
||||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
|
||||||
invoked = true;
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
final controller = IosAudioSessionController(
|
|
||||||
channel: channel,
|
|
||||||
isIos: false,
|
|
||||||
);
|
|
||||||
await controller.activate();
|
|
||||||
await controller.deactivate();
|
|
||||||
|
|
||||||
expect(invoked, isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('activate swallows PlatformException so engine keeps running',
|
|
||||||
() async {
|
|
||||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
|
||||||
throw PlatformException(code: 'avaudiosession_failed');
|
|
||||||
});
|
|
||||||
|
|
||||||
final controller = IosAudioSessionController(
|
|
||||||
channel: channel,
|
|
||||||
isIos: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expectLater(controller.activate(), completes);
|
|
||||||
await expectLater(controller.deactivate(), completes);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('activate swallows MissingPluginException when channel is absent',
|
|
||||||
() async {
|
|
||||||
final controller = IosAudioSessionController(
|
|
||||||
channel: channel,
|
|
||||||
isIos: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expectLater(controller.activate(), completes);
|
|
||||||
await expectLater(controller.deactivate(), completes);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -301,61 +301,6 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test(
|
|
||||||
'SWE4-UV / SRS-300: checkLocalNetworkAccess() emits outbound '
|
|
||||||
'checkLocalNetworkAccess MethodCall with host and port arguments',
|
|
||||||
() async {
|
|
||||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
|
||||||
|
|
||||||
outgoingResponder = (call) async {
|
|
||||||
if (call.method == methodCheckLocalNetworkAccess) {
|
|
||||||
return 'Denied';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
final result = await svc.checkLocalNetworkAccess(
|
|
||||||
host: '192.168.1.42',
|
|
||||||
port: 9987,
|
|
||||||
);
|
|
||||||
|
|
||||||
final calls = outgoingCalls
|
|
||||||
.where((c) => c.method == methodCheckLocalNetworkAccess)
|
|
||||||
.toList();
|
|
||||||
expect(calls, hasLength(1));
|
|
||||||
final args = calls.single.arguments as Map;
|
|
||||||
expect(args['host'], '192.168.1.42');
|
|
||||||
expect(args['port'], 9987);
|
|
||||||
expect(result, MacOSLocalNetworkState.denied);
|
|
||||||
expect(svc.localNetworkState.value, MacOSLocalNetworkState.denied);
|
|
||||||
svc.dispose();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
'SWE4-UV / SRS-300: checkLocalNetworkAccess() parses Granted and updates '
|
|
||||||
'localNetworkState',
|
|
||||||
() async {
|
|
||||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
|
||||||
|
|
||||||
outgoingResponder = (call) async {
|
|
||||||
if (call.method == methodCheckLocalNetworkAccess) {
|
|
||||||
return 'Granted';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
final result = await svc.checkLocalNetworkAccess(
|
|
||||||
host: 'ts.example.com',
|
|
||||||
port: 9987,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result, MacOSLocalNetworkState.granted);
|
|
||||||
expect(svc.localNetworkState.value, MacOSLocalNetworkState.granted);
|
|
||||||
svc.dispose();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'SWE4-UV / SysRS-166: requestNotifications() emits outbound '
|
'SWE4-UV / SysRS-166: requestNotifications() emits outbound '
|
||||||
'requestNotifications MethodCall; returns the platform response',
|
'requestNotifications MethodCall; returns the platform response',
|
||||||
@@ -557,30 +502,4 @@ void main() {
|
|||||||
svc.dispose();
|
svc.dispose();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test(
|
|
||||||
'SWE4-UV / SRS-300: checkLocalNetworkAccess() returns cached state '
|
|
||||||
'when the channel throws',
|
|
||||||
() async {
|
|
||||||
final svc = MacOSPermissionsService(channel: channel)..start();
|
|
||||||
|
|
||||||
outgoingResponder = (call) async {
|
|
||||||
if (call.method == methodCheckLocalNetworkAccess) {
|
|
||||||
throw PlatformException(code: 'probe-failed');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Probe path failures (e.g. NWConnection couldn't establish a
|
|
||||||
// listener, or the Swift side threw) must not crash callers
|
|
||||||
// — they must fall back to whatever the service already cached.
|
|
||||||
final result = await svc.checkLocalNetworkAccess(
|
|
||||||
host: '127.0.0.1',
|
|
||||||
port: 9987,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result, isNotNull);
|
|
||||||
svc.dispose();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
|
||||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
|
||||||
import 'package:chanora_flutter/widgets/chat_panel.dart';
|
|
||||||
import 'package:chanora_flutter/widgets/chat_views.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
rust.BridgeSnapshot snapshot() {
|
|
||||||
final channelId = BigInt.from(10);
|
|
||||||
return rust.BridgeSnapshot(
|
|
||||||
serverName: 'Server',
|
|
||||||
welcomeMessage: '',
|
|
||||||
platform: '',
|
|
||||||
version: '',
|
|
||||||
channels: [
|
|
||||||
rust.BridgeChannel(
|
|
||||||
id: channelId,
|
|
||||||
parent: BigInt.zero,
|
|
||||||
name: 'Lobby',
|
|
||||||
order: 0,
|
|
||||||
hasPassword: false,
|
|
||||||
neededTalkPower: 0,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
clients: [
|
|
||||||
rust.BridgeClient(
|
|
||||||
id: BigInt.one,
|
|
||||||
channel: channelId,
|
|
||||||
name: 'Me',
|
|
||||||
inputMuted: false,
|
|
||||||
outputMuted: false,
|
|
||||||
isSpeaking: false,
|
|
||||||
isServerQuery: false,
|
|
||||||
talkPower: 0,
|
|
||||||
talkPowerGranted: true,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
ownClientId: BigInt.one,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
testWidgets('inline chat panel renders target, messages, and close action', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
var closed = false;
|
|
||||||
final messages = [
|
|
||||||
ChatEntry(
|
|
||||||
senderId: BigInt.from(2),
|
|
||||||
senderName: 'Alice',
|
|
||||||
message: 'Hello from channel',
|
|
||||||
target: const rust.BridgeMessageTarget.channel(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
|
||||||
supportedLocales: AppL10n.supportedLocales,
|
|
||||||
home: Scaffold(
|
|
||||||
body: ChatPanel(
|
|
||||||
messages: messages,
|
|
||||||
snapshot: snapshot(),
|
|
||||||
target: const rust.BridgeMessageTarget.channel(),
|
|
||||||
clientName: '',
|
|
||||||
onClose: () => closed = true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.text('# Lobby'), findsOneWidget);
|
|
||||||
expect(find.text('Hello from channel'), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Close chat'));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(closed, isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('chat detail restores target drafts when the target changes', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
String? savedDraft;
|
|
||||||
final messages = <ChatEntry>[];
|
|
||||||
|
|
||||||
Widget detail({
|
|
||||||
required rust.BridgeMessageTarget target,
|
|
||||||
required String? restoredDraft,
|
|
||||||
}) {
|
|
||||||
return MaterialApp(
|
|
||||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
|
||||||
supportedLocales: AppL10n.supportedLocales,
|
|
||||||
home: Scaffold(
|
|
||||||
body: ChatDetailView(
|
|
||||||
messages: messages,
|
|
||||||
snapshot: snapshot(),
|
|
||||||
target: target,
|
|
||||||
clientName: '',
|
|
||||||
currentChannelId: BigInt.from(10),
|
|
||||||
channelName: 'Lobby',
|
|
||||||
restoredDraft: restoredDraft,
|
|
||||||
onDraftChanged: (text) => savedDraft = text,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
detail(
|
|
||||||
target: const rust.BridgeMessageTarget.channel(),
|
|
||||||
restoredDraft: 'channel draft',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
|
||||||
'channel draft',
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.enterText(find.byType(TextField), 'typed channel draft');
|
|
||||||
await tester.pumpWidget(
|
|
||||||
detail(
|
|
||||||
target: const rust.BridgeMessageTarget.server(),
|
|
||||||
restoredDraft: 'server draft',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(savedDraft, 'typed channel draft');
|
|
||||||
expect(
|
|
||||||
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
|
||||||
'server draft',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'chat detail propagates empty draft when the user clears it before switching target',
|
|
||||||
(tester) async {
|
|
||||||
// Regression: previously, _ChatDetailViewState only emitted
|
|
||||||
// onDraftChanged when the text was non-empty. If the user
|
|
||||||
// restored a saved draft, deleted it, then switched target,
|
|
||||||
// the stale entry stayed in the parent's draft map and
|
|
||||||
// resurrected on the next target swap.
|
|
||||||
String? savedDraft = 'sentinel-unset';
|
|
||||||
final messages = <ChatEntry>[];
|
|
||||||
|
|
||||||
Widget detail({
|
|
||||||
required rust.BridgeMessageTarget target,
|
|
||||||
required String? restoredDraft,
|
|
||||||
}) {
|
|
||||||
return MaterialApp(
|
|
||||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
|
||||||
supportedLocales: AppL10n.supportedLocales,
|
|
||||||
home: Scaffold(
|
|
||||||
body: ChatDetailView(
|
|
||||||
messages: messages,
|
|
||||||
snapshot: snapshot(),
|
|
||||||
target: target,
|
|
||||||
clientName: '',
|
|
||||||
currentChannelId: BigInt.from(10),
|
|
||||||
channelName: 'Lobby',
|
|
||||||
restoredDraft: restoredDraft,
|
|
||||||
onDraftChanged: (text) => savedDraft = text,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
detail(
|
|
||||||
target: const rust.BridgeMessageTarget.channel(),
|
|
||||||
restoredDraft: 'previously saved channel draft',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
|
||||||
'previously saved channel draft',
|
|
||||||
);
|
|
||||||
|
|
||||||
// User clears the field, then switches target.
|
|
||||||
await tester.enterText(find.byType(TextField), '');
|
|
||||||
await tester.pumpWidget(
|
|
||||||
detail(
|
|
||||||
target: const rust.BridgeMessageTarget.server(),
|
|
||||||
restoredDraft: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The empty string MUST reach the parent so the stale entry
|
|
||||||
// is overwritten in the draft map. With the previous guarded
|
|
||||||
// implementation, savedDraft would still hold the sentinel.
|
|
||||||
expect(
|
|
||||||
savedDraft,
|
|
||||||
'',
|
|
||||||
reason: 'empty draft must overwrite stale entry on target swap',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -52,13 +52,11 @@ void main() {
|
|||||||
String welcomeMessage = '',
|
String welcomeMessage = '',
|
||||||
BigInt? ownClientId,
|
BigInt? ownClientId,
|
||||||
BigInt? currentVoiceChannelId,
|
BigInt? currentVoiceChannelId,
|
||||||
Set<BigInt> unreadChannelIds = const {},
|
|
||||||
rust.BridgeAudioStats? audioStats,
|
rust.BridgeAudioStats? audioStats,
|
||||||
bool enableClientLongPressMenu = false,
|
bool enableClientLongPressMenu = false,
|
||||||
ValueChanged<rust.BridgeClient>? onOpenClientInfo,
|
ValueChanged<rust.BridgeClient>? onOpenClientInfo,
|
||||||
ValueChanged<rust.BridgeClient>? onOpenClientChat,
|
ValueChanged<rust.BridgeClient>? onOpenClientChat,
|
||||||
ValueChanged<rust.BridgeClient>? onOpenClientPoke,
|
ValueChanged<rust.BridgeClient>? onOpenClientPoke,
|
||||||
ValueChanged<rust.BridgeChannel>? onOpenChannelChat,
|
|
||||||
}) {
|
}) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
@@ -81,14 +79,12 @@ void main() {
|
|||||||
localOutputMuted: false,
|
localOutputMuted: false,
|
||||||
hasJoinPending: false,
|
hasJoinPending: false,
|
||||||
canJoinVoiceChannel: true,
|
canJoinVoiceChannel: true,
|
||||||
unreadChannelIds: unreadChannelIds,
|
|
||||||
onJoinChannel: (_) {},
|
onJoinChannel: (_) {},
|
||||||
onJoinChannelWithPassword: (_) {},
|
onJoinChannelWithPassword: (_) {},
|
||||||
enableClientLongPressMenu: enableClientLongPressMenu,
|
enableClientLongPressMenu: enableClientLongPressMenu,
|
||||||
onOpenClientInfo: onOpenClientInfo,
|
onOpenClientInfo: onOpenClientInfo,
|
||||||
onOpenClientChat: onOpenClientChat,
|
onOpenClientChat: onOpenClientChat,
|
||||||
onOpenClientPoke: onOpenClientPoke,
|
onOpenClientPoke: onOpenClientPoke,
|
||||||
onOpenChannelChat: onOpenChannelChat,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -137,32 +133,6 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders unread dot on channels with unread chat messages', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
snapshotHarness(
|
|
||||||
channels: [channel(id: 1, name: 'Lobby')],
|
|
||||||
clients: const [],
|
|
||||||
unreadChannelIds: {BigInt.one},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
find.byWidgetPredicate(
|
|
||||||
(widget) =>
|
|
||||||
widget is Container &&
|
|
||||||
widget.constraints?.maxWidth == 8 &&
|
|
||||||
widget.constraints?.maxHeight == 8 &&
|
|
||||||
widget.decoration is BoxDecoration &&
|
|
||||||
(widget.decoration! as BoxDecoration).shape == BoxShape.circle,
|
|
||||||
),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('collapsing a channel hides users and child channels', (
|
testWidgets('collapsing a channel hides users and child channels', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -523,7 +493,6 @@ void main() {
|
|||||||
framesSent: 1,
|
framesSent: 1,
|
||||||
framesReceived: 0,
|
framesReceived: 0,
|
||||||
pttActive: true,
|
pttActive: true,
|
||||||
inputLevel: -30.0,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -580,7 +549,6 @@ void main() {
|
|||||||
localOutputMuted: false,
|
localOutputMuted: false,
|
||||||
hasJoinPending: false,
|
hasJoinPending: false,
|
||||||
canJoinVoiceChannel: true,
|
canJoinVoiceChannel: true,
|
||||||
unreadChannelIds: const {},
|
|
||||||
onJoinChannel: (channel) => tapped = channel,
|
onJoinChannel: (channel) => tapped = channel,
|
||||||
onJoinChannelWithPassword: (_) {},
|
onJoinChannelWithPassword: (_) {},
|
||||||
),
|
),
|
||||||
@@ -608,64 +576,6 @@ void main() {
|
|||||||
expect(tapped!.neededTalkPower, 12);
|
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', (
|
testWidgets('separator spacers render as line painters without raw text', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -700,7 +610,6 @@ void main() {
|
|||||||
localOutputMuted: false,
|
localOutputMuted: false,
|
||||||
hasJoinPending: false,
|
hasJoinPending: false,
|
||||||
canJoinVoiceChannel: true,
|
canJoinVoiceChannel: true,
|
||||||
unreadChannelIds: const {},
|
|
||||||
onJoinChannel: (_) {},
|
onJoinChannel: (_) {},
|
||||||
onJoinChannelWithPassword: (_) {},
|
onJoinChannelWithPassword: (_) {},
|
||||||
),
|
),
|
||||||
@@ -755,7 +664,6 @@ void main() {
|
|||||||
localOutputMuted: false,
|
localOutputMuted: false,
|
||||||
hasJoinPending: false,
|
hasJoinPending: false,
|
||||||
canJoinVoiceChannel: true,
|
canJoinVoiceChannel: true,
|
||||||
unreadChannelIds: const {},
|
|
||||||
onJoinChannel: (_) {},
|
onJoinChannel: (_) {},
|
||||||
onJoinChannelWithPassword: (_) {},
|
onJoinChannelWithPassword: (_) {},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
|
||||||
import 'package:chanora_flutter/widgets/voice_compact.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('touch PTT releases when disposed while held', (tester) async {
|
|
||||||
final heldChanges = <bool>[];
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
|
||||||
supportedLocales: AppL10n.supportedLocales,
|
|
||||||
home: Scaffold(
|
|
||||||
body: VoicePttButton(
|
|
||||||
active: false,
|
|
||||||
onHeldChanged: heldChanges.add,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final center = tester.getCenter(find.byType(VoicePttButton));
|
|
||||||
final gesture = await tester.startGesture(center);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(heldChanges, [true]);
|
|
||||||
|
|
||||||
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
|
|
||||||
|
|
||||||
expect(heldChanges, [true, false]);
|
|
||||||
|
|
||||||
await gesture.cancel();
|
|
||||||
|
|
||||||
expect(heldChanges, [true, false]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,7 @@ chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
|
|||||||
chanora_prefetch = { path = "../../crates/chanora_prefetch" }
|
chanora_prefetch = { path = "../../crates/chanora_prefetch" }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
tokio = { version = "1", features = ["sync", "rt", "macros"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Used by integration tests to inspect the bookmark DB row layout
|
# Used by integration tests to inspect the bookmark DB row layout
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
+305
-149
@@ -52,8 +52,6 @@ use chanora_state::channel_join::{
|
|||||||
ConnectionEpoch, JoinFailureKind,
|
ConnectionEpoch, JoinFailureKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod events;
|
|
||||||
mod network_diagnostics;
|
|
||||||
pub mod ptt;
|
pub mod ptt;
|
||||||
|
|
||||||
pub use chanora_audio::{
|
pub use chanora_audio::{
|
||||||
@@ -71,11 +69,46 @@ pub use chanora_protocol::{
|
|||||||
MessageTarget, ProtocolError, ServerActivity, ServerSnapshot,
|
MessageTarget, ProtocolError, ServerActivity, ServerSnapshot,
|
||||||
};
|
};
|
||||||
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
|
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
|
||||||
pub use events::{
|
|
||||||
NetworkState, PersistedPttBinding, PttDescriptorSnapshot, SessionEvent, VoiceJoinErrorCode,
|
/// Privacy-safe snapshot of the active PTT capability.
|
||||||
VoiceJoinSyncState,
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
};
|
pub struct PttDescriptorSnapshot {
|
||||||
use network_diagnostics::NetworkDiagnostics;
|
/// Stable capability level name.
|
||||||
|
pub level: String,
|
||||||
|
/// Stable backend identifier.
|
||||||
|
pub backend_id: String,
|
||||||
|
/// Coarse bound input class; empty when no binding is active.
|
||||||
|
pub bound_input_class: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
|
||||||
|
fn from(desc: PttBackendDescriptor) -> Self {
|
||||||
|
Self {
|
||||||
|
level: desc.level.as_str().to_string(),
|
||||||
|
backend_id: desc.backend_id.to_string(),
|
||||||
|
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted PTT binding state exposed to callers.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PersistedPttBinding {
|
||||||
|
/// Stable input category string (`""`, `"keyboard"`, or
|
||||||
|
/// `"mouse-side-button"`).
|
||||||
|
pub input_class: String,
|
||||||
|
/// Display-only key label; empty when no binding is active.
|
||||||
|
pub key_label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PersistedPttBinding {
|
||||||
|
fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
input_class: String::new(),
|
||||||
|
key_label: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Errors that can arise during top-level orchestration.
|
/// Errors that can arise during top-level orchestration.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -114,11 +147,253 @@ pub enum CoreError {
|
|||||||
Ptt(#[from] ptt::PttControllerError),
|
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
|
/// Channel capacity for the broadcast events. Generous because
|
||||||
/// reconnect cycles emit several events per attempt; if subscribers
|
/// reconnect cycles emit several events per attempt; if subscribers
|
||||||
/// fall behind we'd rather skip than block the supervisor.
|
/// fall behind we'd rather skip than block the supervisor.
|
||||||
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
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 {
|
struct SupervisorInner {
|
||||||
/// Optional cached AudioEngineConfig — set when start_audio is
|
/// Optional cached AudioEngineConfig — set when start_audio is
|
||||||
/// first called, used to re-create the engine after a reconnect.
|
/// first called, used to re-create the engine after a reconnect.
|
||||||
@@ -164,10 +439,6 @@ struct ConnectedState {
|
|||||||
local_output_muted: bool,
|
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> {
|
fn normalize_channel_password(password: Option<String>) -> Option<String> {
|
||||||
password
|
password
|
||||||
.map(|p| p.trim().to_string())
|
.map(|p| p.trim().to_string())
|
||||||
@@ -1036,8 +1307,8 @@ impl ChanoraSession {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read audio engine statistics: (frames_sent, frames_received, transmit_active, input_level_dbfs).
|
/// Read audio engine statistics: (frames_sent, frames_received, transmit_active).
|
||||||
pub async fn audio_stats(&self) -> Result<(u32, u32, bool, f32), CoreError> {
|
pub async fn audio_stats(&self) -> Result<(u32, u32, bool), CoreError> {
|
||||||
let guard = self.inner.lock().await;
|
let guard = self.inner.lock().await;
|
||||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
|
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
|
||||||
@@ -1045,7 +1316,6 @@ impl ChanoraSession {
|
|||||||
audio.frames_sent(),
|
audio.frames_sent(),
|
||||||
audio.frames_received(),
|
audio.frames_received(),
|
||||||
audio.transmit_active(),
|
audio.transmit_active(),
|
||||||
audio.input_level(),
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1569,7 +1839,8 @@ impl ChanoraSession {
|
|||||||
|
|
||||||
/// Disconnect from the server. No-op if not connected.
|
/// Disconnect from the server. No-op if not connected.
|
||||||
pub async fn disconnect(&self) -> Result<(), CoreError> {
|
pub async fn disconnect(&self) -> Result<(), CoreError> {
|
||||||
if let Some(mut state) = take_disconnect_state(&self.inner).await {
|
let mut guard = self.inner.lock().await;
|
||||||
|
if let Some(mut state) = guard.take() {
|
||||||
// Signal the supervisor to exit (cancels any backoff sleep).
|
// Signal the supervisor to exit (cancels any backoff sleep).
|
||||||
if let Some(tx) = state.cancel_tx.take() {
|
if let Some(tx) = state.cancel_tx.take() {
|
||||||
let _ = tx.send(());
|
let _ = tx.send(());
|
||||||
@@ -1585,7 +1856,7 @@ impl ChanoraSession {
|
|||||||
// Wait for the supervisor to wind down so we don't race
|
// Wait for the supervisor to wind down so we don't race
|
||||||
// a redial against the explicit disconnect.
|
// a redial against the explicit disconnect.
|
||||||
if let Some(handle) = state.supervisor.take() {
|
if let Some(handle) = state.supervisor.take() {
|
||||||
await_supervisor_shutdown(handle, SUPERVISOR_SHUTDOWN_TIMEOUT).await;
|
let _ = handle.await;
|
||||||
}
|
}
|
||||||
let _ = self.events_tx.send(SessionEvent::Disconnected {
|
let _ = self.events_tx.send(SessionEvent::Disconnected {
|
||||||
reason: "user requested".to_string(),
|
reason: "user requested".to_string(),
|
||||||
@@ -1624,22 +1895,6 @@ const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
|||||||
/// Number of consecutive watchdog failures before the supervisor
|
/// Number of consecutive watchdog failures before the supervisor
|
||||||
/// declares the connection lost.
|
/// declares the connection lost.
|
||||||
const WATCHDOG_MAX_MISSES: u32 = 3;
|
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 {
|
struct SupervisorContext {
|
||||||
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
||||||
@@ -1695,77 +1950,27 @@ fn spawn_event_forwarders(
|
|||||||
let mut rx = delta_rx;
|
let mut rx = delta_rx;
|
||||||
while let Some(delta) = rx.recv().await {
|
while let Some(delta) = rx.recv().await {
|
||||||
let event = match delta {
|
let event = match delta {
|
||||||
ProtocolDelta::ClientMoved {
|
ProtocolDelta::ClientMoved { client_id, new_channel_id } => {
|
||||||
client_id,
|
SessionEvent::ClientMoved { client_id, new_channel_id }
|
||||||
new_channel_id,
|
}
|
||||||
} => SessionEvent::ClientMoved {
|
ProtocolDelta::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||||
client_id,
|
SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||||
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 } => {
|
ProtocolDelta::ClientLeft { client_id, name } => {
|
||||||
SessionEvent::ClientLeft { client_id, name }
|
SessionEvent::ClientLeft { client_id, name }
|
||||||
}
|
}
|
||||||
ProtocolDelta::ClientUpdated {
|
ProtocolDelta::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||||
client_id,
|
SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||||
input_muted,
|
}
|
||||||
output_muted,
|
ProtocolDelta::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
|
||||||
is_server_query,
|
SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
|
||||||
talk_power,
|
}
|
||||||
talk_power_granted,
|
ProtocolDelta::ChannelRemoved { id } => {
|
||||||
} => SessionEvent::ClientUpdated {
|
SessionEvent::ChannelRemoved { id }
|
||||||
client_id,
|
}
|
||||||
input_muted,
|
ProtocolDelta::ChannelUpdated { id, name, has_password, needed_talk_power } => {
|
||||||
output_muted,
|
SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
|
||||||
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);
|
let _ = ev_tx.send(event);
|
||||||
}
|
}
|
||||||
@@ -2194,7 +2399,6 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
|||||||
/// the UI would render. Two snapshots with identical channel
|
/// the UI would render. Two snapshots with identical channel
|
||||||
/// memberships, names, and orderings produce the same signature;
|
/// memberships, names, and orderings produce the same signature;
|
||||||
/// any in-channel move, rename, or reorder produces a different one.
|
/// any in-channel move, rename, or reorder produces a different one.
|
||||||
#[cfg(test)]
|
|
||||||
fn snapshot_signature(snap: &ServerSnapshot) -> u64 {
|
fn snapshot_signature(snap: &ServerSnapshot) -> u64 {
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
@@ -2389,54 +2593,6 @@ mod tests {
|
|||||||
s.disconnect().await.unwrap();
|
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]
|
#[test]
|
||||||
fn signature_detects_in_channel_move() {
|
fn signature_detects_in_channel_move() {
|
||||||
use chanora_protocol::{ChannelInfo, ClientInfo};
|
use chanora_protocol::{ChannelInfo, ClientInfo};
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
use std::collections::VecDeque;
|
|
||||||
|
|
||||||
/// Network diagnostics snapshot collected across connection lifetimes.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub(crate) struct NetworkDiagnostics {
|
|
||||||
/// Total count of connects (including the initial one).
|
|
||||||
connect_count: u64,
|
|
||||||
/// Count of disconnects (graceful + loss).
|
|
||||||
disconnect_count: u64,
|
|
||||||
/// Recent loss reasons (last 8, ring buffer).
|
|
||||||
loss_reasons: VecDeque<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NetworkDiagnostics {
|
|
||||||
pub(crate) fn record_connect(&mut self) {
|
|
||||||
self.connect_count = self.connect_count.saturating_add(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn record_loss(&mut self, reason: &str) {
|
|
||||||
self.disconnect_count = self.disconnect_count.saturating_add(1);
|
|
||||||
if self.loss_reasons.len() >= 8 {
|
|
||||||
self.loss_reasons.pop_front();
|
|
||||||
}
|
|
||||||
self.loss_reasons.push_back(reason.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn summary(&self) -> String {
|
|
||||||
let mut s = format!(
|
|
||||||
"connects: {}\ndisconnects: {}\n",
|
|
||||||
self.connect_count, self.disconnect_count
|
|
||||||
);
|
|
||||||
if !self.loss_reasons.is_empty() {
|
|
||||||
s.push_str(&format!(
|
|
||||||
"loss_reasons: [{}]\n",
|
|
||||||
self.loss_reasons
|
|
||||||
.iter()
|
|
||||||
.map(String::as_str)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn network_diagnostics_keeps_last_eight_loss_reasons() {
|
|
||||||
let mut diagnostics = NetworkDiagnostics::default();
|
|
||||||
|
|
||||||
for i in 0..10 {
|
|
||||||
diagnostics.record_loss(&format!("loss-{i}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(diagnostics.disconnect_count, 10);
|
|
||||||
assert_eq!(diagnostics.loss_reasons.len(), 8);
|
|
||||||
assert_eq!(
|
|
||||||
diagnostics.loss_reasons.front().map(String::as_str),
|
|
||||||
Some("loss-2")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
diagnostics.loss_reasons.back().map(String::as_str),
|
|
||||||
Some("loss-9")
|
|
||||||
);
|
|
||||||
assert!(diagnostics.summary().contains(
|
|
||||||
"loss_reasons: [loss-2, loss-3, loss-4, loss-5, loss-6, loss-7, loss-8, loss-9]"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,12 +30,13 @@ tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2
|
|||||||
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
||||||
rustfft = "6.2.0"
|
rustfft = "6.2.0"
|
||||||
crossbeam = { version = "0.8", default-features = false, features = ["alloc", "crossbeam-queue"] }
|
crossbeam = { version = "0.8", default-features = false, features = ["alloc", "crossbeam-queue"] }
|
||||||
|
crossbeam-utils = { version = "0.8", default-features = false }
|
||||||
|
|
||||||
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
|
[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.
|
# Desktop audio I/O for Windows capture/playback and Linux capture.
|
||||||
# Linux playback uses SDL2; Apple platforms use direct VoiceProcessingIO
|
# Linux playback uses SDL2; Apple platforms use direct VoiceProcessingIO
|
||||||
# AudioUnits via `coreaudio-rs` for the voice path.
|
# AudioUnits via `coreaudio-rs` for the voice path.
|
||||||
cpal = "0.18.0"
|
cpal = "0.17.3"
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
|
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
|
||||||
# Direct CoreAudio AudioUnit access on Apple platforms (DEC-011 follow-up).
|
# Direct CoreAudio AudioUnit access on Apple platforms (DEC-011 follow-up).
|
||||||
@@ -58,7 +59,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
|
# JNI bindings to flip Android's AudioManager into MODE_IN_COMMUNICATION
|
||||||
# when the voice-comm preset is requested. ndk_context is initialised
|
# when the voice-comm preset is requested. ndk_context is initialised
|
||||||
# by the bridge crate's android_init shim.
|
# by the bridge crate's android_init shim.
|
||||||
jni = { version = "0.22.4", default-features = false }
|
jni = { version = "0.21", default-features = false }
|
||||||
ndk-context = "0.1"
|
ndk-context = "0.1"
|
||||||
# Oboe-rs (Google Oboe wrapper) for low-latency voice capture + playback.
|
# Oboe-rs (Google Oboe wrapper) for low-latency voice capture + playback.
|
||||||
# Primary backend for SDD-111..SDD-115. The pre-compiled static library
|
# Primary backend for SDD-111..SDD-115. The pre-compiled static library
|
||||||
@@ -73,18 +74,13 @@ ndk-context = "0.1"
|
|||||||
# - deduplicated macro impls
|
# - deduplicated macro impls
|
||||||
# - PowerSavingOffloaded PerformanceMode variant
|
# - PowerSavingOffloaded PerformanceMode variant
|
||||||
oboe = { git = "https://github.com/EdisonJwa/oboe-rs", rev = "a14f9b83ecea8c93f5a692f2ee7808445b938c35" }
|
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]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
# Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices
|
# Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices
|
||||||
# + WM_INPUT translation backed by a hidden message-only window, and
|
# + WM_INPUT translation backed by a hidden message-only window, and
|
||||||
# SetWindowsHookExW(WH_KEYBOARD_LL / WH_MOUSE_LL) fallback. Both
|
# SetWindowsHookExW(WH_KEYBOARD_LL / WH_MOUSE_LL) fallback. Both
|
||||||
# require a per-backend OS thread that owns a message pump.
|
# require a per-backend OS thread that owns a message pump.
|
||||||
windows = { version = "0.62", features = [
|
windows = { version = "0.54", features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_Graphics_Gdi",
|
"Win32_Graphics_Gdi",
|
||||||
"Win32_System_LibraryLoader",
|
"Win32_System_LibraryLoader",
|
||||||
@@ -104,7 +100,7 @@ tracing-subscriber = { version = "0.3", features = ["registry"] }
|
|||||||
# SDD-120 §3 — criterion bench harness (realtime_capture / opus_codec /
|
# SDD-120 §3 — criterion bench harness (realtime_capture / opus_codec /
|
||||||
# resampler). `harness = false` per bench entry below disables the
|
# resampler). `harness = false` per bench entry below disables the
|
||||||
# default libtest harness so criterion can install its own.
|
# default libtest harness so criterion can install its own.
|
||||||
criterion = "0.8"
|
criterion = "0.5"
|
||||||
# SDD-120 §3 item 1 — dhat is used as the global allocator inside
|
# SDD-120 §3 item 1 — dhat is used as the global allocator inside
|
||||||
# `benches/realtime_capture.rs` to count post-warmup heap allocations
|
# `benches/realtime_capture.rs` to count post-warmup heap allocations
|
||||||
# on the realtime capture path. Dev-dep only — does NOT affect
|
# on the realtime capture path. Dev-dep only — does NOT affect
|
||||||
@@ -147,7 +143,7 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] }
|
|||||||
# Random token bytes for the portal handle_token / session_handle_token
|
# Random token bytes for the portal handle_token / session_handle_token
|
||||||
# options. The portal recommends fresh tokens to scope its own
|
# options. The portal recommends fresh tokens to scope its own
|
||||||
# object paths per call.
|
# object paths per call.
|
||||||
rand = "0.10"
|
rand = "0.8"
|
||||||
# SDL2 audio for Linux. Replaces the cpal playback path on Linux only;
|
# SDL2 audio for Linux. Replaces the cpal playback path on Linux only;
|
||||||
# cpal stays in use for Linux capture and Windows capture/playback.
|
# cpal stays in use for Linux capture and Windows capture/playback.
|
||||||
# Apple platforms use direct VoiceProcessingIO AudioUnits. Rationale: the
|
# Apple platforms use direct VoiceProcessingIO AudioUnits. Rationale: the
|
||||||
@@ -166,4 +162,4 @@ rand = "0.10"
|
|||||||
# libSDL2.so. Arch ships `sdl2-compat`; Debian/Ubuntu ship
|
# libSDL2.so. Arch ships `sdl2-compat`; Debian/Ubuntu ship
|
||||||
# `libsdl2-2.0-0`; Fedora ships `SDL2`. The chanora-flutter Linux
|
# `libsdl2-2.0-0`; Fedora ships `SDL2`. The chanora-flutter Linux
|
||||||
# build documentation lists this as a runtime dependency.
|
# build documentation lists this as a runtime dependency.
|
||||||
sdl2 = { version = "0.38", default-features = false }
|
sdl2 = { version = "0.37", default-features = false }
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crossbeam::queue::ArrayQueue;
|
|
||||||
|
|
||||||
/// Fixed-capacity PCM handoff from the Android render producer task to
|
|
||||||
/// the Oboe output callback.
|
|
||||||
pub(crate) struct AndroidRenderRing {
|
|
||||||
frames: Arc<ArrayQueue<[f32; 2]>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AndroidRenderRing {
|
|
||||||
pub(crate) fn new(capacity: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
frames: Arc::new(ArrayQueue::new((capacity / 2).max(1))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn producer(&self) -> AndroidRenderRingProducer {
|
|
||||||
AndroidRenderRingProducer {
|
|
||||||
frames: Arc::clone(&self.frames),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn consumer(&self) -> AndroidRenderRingConsumer {
|
|
||||||
AndroidRenderRingConsumer {
|
|
||||||
frames: Arc::clone(&self.frames),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct AndroidRenderRingProducer {
|
|
||||||
frames: Arc<ArrayQueue<[f32; 2]>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AndroidRenderRingProducer {
|
|
||||||
pub(crate) fn push_frame_lossy(&self, samples: &[f32]) {
|
|
||||||
for frame in samples.chunks_exact(2) {
|
|
||||||
let stereo_frame = [frame[0], frame[1]];
|
|
||||||
if self.frames.push(stereo_frame).is_err() {
|
|
||||||
let _ = self.frames.pop();
|
|
||||||
let _ = self.frames.push(stereo_frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct AndroidRenderRingConsumer {
|
|
||||||
frames: Arc<ArrayQueue<[f32; 2]>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AndroidRenderRingConsumer {
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn drain_into_zero_filling(&self, out: &mut [f32]) {
|
|
||||||
let mut chunks = out.chunks_exact_mut(2);
|
|
||||||
for frame_out in &mut chunks {
|
|
||||||
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
|
|
||||||
frame_out.copy_from_slice(&frame);
|
|
||||||
}
|
|
||||||
for sample in chunks.into_remainder() {
|
|
||||||
*sample = 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn drain_stereo_into_zero_filling(&self, out: &mut [(f32, f32)]) {
|
|
||||||
for frame_out in out {
|
|
||||||
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
|
|
||||||
*frame_out = (frame[0], frame[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn producer_drops_oldest_samples_when_ring_is_full() {
|
|
||||||
let ring = AndroidRenderRing::new(4);
|
|
||||||
let producer = ring.producer();
|
|
||||||
let consumer = ring.consumer();
|
|
||||||
|
|
||||||
producer.push_frame_lossy(&[1.0, 2.0, 3.0, 4.0]);
|
|
||||||
producer.push_frame_lossy(&[5.0, 6.0]);
|
|
||||||
|
|
||||||
let mut out = [0.0; 4];
|
|
||||||
consumer.drain_into_zero_filling(&mut out);
|
|
||||||
|
|
||||||
assert_eq!(out, [3.0, 4.0, 5.0, 6.0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn overflow_after_partial_consumer_drain_preserves_stereo_pairing() {
|
|
||||||
let ring = AndroidRenderRing::new(4);
|
|
||||||
let producer = ring.producer();
|
|
||||||
let consumer = ring.consumer();
|
|
||||||
|
|
||||||
producer.push_frame_lossy(&[1.0, 10.0, 2.0, 20.0]);
|
|
||||||
|
|
||||||
let mut odd_out = [9.0];
|
|
||||||
consumer.drain_into_zero_filling(&mut odd_out);
|
|
||||||
assert_eq!(odd_out, [0.0]);
|
|
||||||
|
|
||||||
producer.push_frame_lossy(&[3.0, 30.0]);
|
|
||||||
|
|
||||||
let mut out = [0.0; 4];
|
|
||||||
consumer.drain_into_zero_filling(&mut out);
|
|
||||||
|
|
||||||
assert_eq!(out, [2.0, 20.0, 3.0, 30.0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn consumer_zero_fills_tail_on_underrun() {
|
|
||||||
let ring = AndroidRenderRing::new(4);
|
|
||||||
let producer = ring.producer();
|
|
||||||
let consumer = ring.consumer();
|
|
||||||
|
|
||||||
producer.push_frame_lossy(&[0.25, -0.25]);
|
|
||||||
|
|
||||||
let mut out = [9.0; 4];
|
|
||||||
consumer.drain_into_zero_filling(&mut out);
|
|
||||||
|
|
||||||
assert_eq!(out, [0.25, -0.25, 0.0, 0.0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -53,6 +53,7 @@ use crate::mobile_voice_backend::{
|
|||||||
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
|
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
|
||||||
SharingModeChoice, VoiceAudioParams,
|
SharingModeChoice, VoiceAudioParams,
|
||||||
};
|
};
|
||||||
|
use chanora_protocol::OutPacket;
|
||||||
use tsclientlib::audio::AudioHandler;
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
|
||||||
use crate::{engine::SessionAudioId, AudioError};
|
use crate::{engine::SessionAudioId, AudioError};
|
||||||
@@ -85,11 +86,40 @@ use crate::processor::AudioProcessor;
|
|||||||
|
|
||||||
const RENDER_REF_SLOTS: usize = 4;
|
const RENDER_REF_SLOTS: usize = 4;
|
||||||
const RENDER_REF_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES;
|
const RENDER_REF_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES;
|
||||||
const ANDROID_RENDER_PULL_SAMPLES: usize = crate::frame::FRAME_20MS_SAMPLES * 2;
|
|
||||||
const ANDROID_RENDER_RING_CAPACITY: usize = ANDROID_RENDER_PULL_SAMPLES * 5;
|
|
||||||
|
|
||||||
type RenderReferenceBuffer =
|
struct RenderReferenceBuffer {
|
||||||
crate::render_reference::RenderReferenceBuffer<RENDER_REF_SAMPLES, RENDER_REF_SLOTS>;
|
buf: Box<[[f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]>,
|
||||||
|
write_idx: std::sync::atomic::AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderReferenceBuffer {
|
||||||
|
fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
buf: Box::new([[0.0_f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]),
|
||||||
|
write_idx: std::sync::atomic::AtomicUsize::new(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, frame: &[f32; RENDER_REF_SAMPLES]) {
|
||||||
|
let idx = self.write_idx.load(Ordering::Relaxed);
|
||||||
|
unsafe {
|
||||||
|
let slot = &self.buf[idx] as *const [f32; RENDER_REF_SAMPLES]
|
||||||
|
as *mut [f32; RENDER_REF_SAMPLES];
|
||||||
|
(*slot).copy_from_slice(frame);
|
||||||
|
}
|
||||||
|
self.write_idx
|
||||||
|
.store((idx + 1) % RENDER_REF_SLOTS, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_latest(&self) -> [f32; RENDER_REF_SAMPLES] {
|
||||||
|
let wi = self.write_idx.load(Ordering::Relaxed);
|
||||||
|
let ri = (wi + RENDER_REF_SLOTS - 1) % RENDER_REF_SLOTS;
|
||||||
|
self.buf[ri]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for RenderReferenceBuffer {}
|
||||||
|
unsafe impl Sync for RenderReferenceBuffer {}
|
||||||
|
|
||||||
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
|
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
|
||||||
//
|
//
|
||||||
@@ -108,8 +138,9 @@ struct AndroidCaptureState {
|
|||||||
encoder: OpusEncoder,
|
encoder: OpusEncoder,
|
||||||
pcm_accum: Vec<i16>,
|
pcm_accum: Vec<i16>,
|
||||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||||
@@ -133,7 +164,7 @@ struct AndroidCaptureState {
|
|||||||
|
|
||||||
impl AndroidCaptureState {
|
impl AndroidCaptureState {
|
||||||
fn new(
|
fn new(
|
||||||
voice_out_tx: mpsc::Sender<chanora_protocol::OutPacket>,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
frames_sent: Arc<AtomicU32>,
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
@@ -157,12 +188,9 @@ impl AndroidCaptureState {
|
|||||||
encoder,
|
encoder,
|
||||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
voice_out_tx,
|
||||||
voice_out_tx,
|
|
||||||
frames_sent.clone(),
|
|
||||||
"android",
|
|
||||||
)?,
|
|
||||||
transmit_active,
|
transmit_active,
|
||||||
|
frames_sent,
|
||||||
mic_gain,
|
mic_gain,
|
||||||
voice_activity_selector,
|
voice_activity_selector,
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||||
@@ -193,10 +221,8 @@ impl AndroidCaptureState {
|
|||||||
self.audio_processing_stats
|
self.audio_processing_stats
|
||||||
.record_callback_frames(samples.len() as u64);
|
.record_callback_frames(samples.len() as u64);
|
||||||
if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ {
|
if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ {
|
||||||
self.resample_capture_to_48k(samples);
|
let resampled = self.resample_capture_to_48k(samples);
|
||||||
let resampled = std::mem::take(&mut self.resample_scratch);
|
|
||||||
self.ingest_48k_i16(&resampled);
|
self.ingest_48k_i16(&resampled);
|
||||||
self.resample_scratch = resampled;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.ingest_48k_i16(samples);
|
self.ingest_48k_i16(samples);
|
||||||
@@ -215,7 +241,6 @@ impl AndroidCaptureState {
|
|||||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||||
let frame = self.pending_10ms;
|
let frame = self.pending_10ms;
|
||||||
self.process_10ms_capture_frame(&frame);
|
self.process_10ms_capture_frame(&frame);
|
||||||
self.encode_complete_20ms_frames();
|
|
||||||
self.pending_10ms_len = 0;
|
self.pending_10ms_len = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,10 +250,6 @@ impl AndroidCaptureState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.encode_complete_20ms_frames();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_complete_20ms_frames(&mut self) {
|
|
||||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||||
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
||||||
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
||||||
@@ -237,6 +258,7 @@ impl AndroidCaptureState {
|
|||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
crate::opus_voice::send_voip_frame(
|
crate::opus_voice::send_voip_frame(
|
||||||
&self.voice_out_tx,
|
&self.voice_out_tx,
|
||||||
|
&self.frames_sent,
|
||||||
&self.opus_out,
|
&self.opus_out,
|
||||||
len,
|
len,
|
||||||
|| {
|
|| {
|
||||||
@@ -264,18 +286,35 @@ impl AndroidCaptureState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> usize {
|
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> Vec<i16> {
|
||||||
let result = crate::capture_resampler::resample_capture_to_48k(
|
if samples.is_empty() {
|
||||||
samples,
|
return Vec::new();
|
||||||
self.input_sample_rate_hz,
|
|
||||||
&mut self.resample_pos,
|
|
||||||
&mut self.resample_last,
|
|
||||||
&mut self.resample_scratch,
|
|
||||||
);
|
|
||||||
if result.dropped {
|
|
||||||
self.audio_processing_stats.increment_callback_xrun();
|
|
||||||
}
|
}
|
||||||
result.output_len
|
self.resample_scratch.clear();
|
||||||
|
let ratio = self.input_sample_rate_hz as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
|
||||||
|
let mut pos = self.resample_pos;
|
||||||
|
while pos < samples.len() as f64 {
|
||||||
|
let i = pos.floor() as isize;
|
||||||
|
let frac = pos - i as f64;
|
||||||
|
let a = if i <= 0 {
|
||||||
|
self.resample_last as f64
|
||||||
|
} else {
|
||||||
|
samples[(i - 1) as usize] as f64
|
||||||
|
};
|
||||||
|
let b = if i < samples.len() as isize {
|
||||||
|
samples[i as usize] as f64
|
||||||
|
} else {
|
||||||
|
a
|
||||||
|
};
|
||||||
|
let value = (a + frac * (b - a))
|
||||||
|
.round()
|
||||||
|
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
|
||||||
|
self.resample_scratch.push(value);
|
||||||
|
pos += ratio;
|
||||||
|
}
|
||||||
|
self.resample_pos = pos - samples.len() as f64;
|
||||||
|
self.resample_last = *samples.last().unwrap_or(&self.resample_last);
|
||||||
|
self.resample_scratch.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_input_sample_rate_hz(&mut self, sample_rate_hz: u32) {
|
fn set_input_sample_rate_hz(&mut self, sample_rate_hz: u32) {
|
||||||
@@ -354,9 +393,15 @@ impl AndroidCaptureState {
|
|||||||
self.fallback_warned_backend = None;
|
self.fallback_warned_backend = None;
|
||||||
match vad_backend {
|
match vad_backend {
|
||||||
crate::VadBackend::SileroOnnx => {
|
crate::VadBackend::SileroOnnx => {
|
||||||
self.silero_vad_worker = None;
|
let path = crate::vad::silero_model_bundle_path();
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
self.silero_vad_worker =
|
||||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&path);
|
||||||
|
if self.silero_vad_worker.is_none() {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
"android: Silero VAD model not found at {path}; falling back to WebRTC VAD"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
self.silero_vad_worker = None;
|
self.silero_vad_worker = None;
|
||||||
@@ -375,38 +420,20 @@ impl AndroidCaptureState {
|
|||||||
speech: true,
|
speech: true,
|
||||||
}
|
}
|
||||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||||
match crate::vad::callback_vad_worker_policy(
|
if let Some(worker) = self.silero_vad_worker.as_ref() {
|
||||||
voice_activity_mode,
|
let enqueued = worker.try_send(capture_seq, &frame);
|
||||||
vad_backend,
|
if !worker.is_stale(capture_seq) {
|
||||||
self.silero_vad_worker.is_some(),
|
let p = worker.latest_probability();
|
||||||
) {
|
crate::vad::VadOutput {
|
||||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
probability: p,
|
||||||
let worker = self
|
speech: p >= 0.5,
|
||||||
.silero_vad_worker
|
|
||||||
.as_ref()
|
|
||||||
.expect("policy checked worker");
|
|
||||||
let enqueued = worker.try_send(capture_seq, &frame);
|
|
||||||
if !worker.is_stale(capture_seq) {
|
|
||||||
let p = worker.latest_probability();
|
|
||||||
crate::vad::VadOutput {
|
|
||||||
probability: p,
|
|
||||||
speech: p >= 0.5,
|
|
||||||
}
|
|
||||||
} else if enqueued {
|
|
||||||
crate::vad::VadOutput {
|
|
||||||
probability: 0.0,
|
|
||||||
speech: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
used_fallback_vad = true;
|
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
|
||||||
&mut self.vad_detector,
|
|
||||||
&frame,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
} else if enqueued {
|
||||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
crate::vad::VadOutput {
|
||||||
|
probability: 0.0,
|
||||||
|
speech: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
used_fallback_vad = true;
|
used_fallback_vad = true;
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
self.mark_vad_fallback_active(vad_backend);
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
@@ -414,10 +441,10 @@ impl AndroidCaptureState {
|
|||||||
&frame,
|
&frame,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
} else {
|
||||||
probability: 1.0,
|
used_fallback_vad = true;
|
||||||
speech: true,
|
self.mark_vad_fallback_active(vad_backend);
|
||||||
},
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
@@ -445,19 +472,21 @@ impl AndroidCaptureState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if crate::capture_accumulator::append_processed_i16_bounded(
|
let gain = self.mic_gain;
|
||||||
&mut self.pcm_accum,
|
if (gain - 1.0).abs() < f32::EPSILON {
|
||||||
&frame,
|
self.pcm_accum
|
||||||
self.mic_gain,
|
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
||||||
) {
|
} else {
|
||||||
self.audio_processing_stats.increment_callback_xrun();
|
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
||||||
|
let scaled = (crate::frame::f32_to_i16(s) as f32) * gain;
|
||||||
|
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct InputCallback {
|
struct InputCallback {
|
||||||
state: Arc<Mutex<AndroidCaptureState>>,
|
state: Arc<Mutex<AndroidCaptureState>>,
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
|
||||||
event_tx: BackendEventTx,
|
event_tx: BackendEventTx,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,13 +498,9 @@ impl AudioInputCallback for InputCallback {
|
|||||||
_stream: &mut dyn AudioInputStreamSafe,
|
_stream: &mut dyn AudioInputStreamSafe,
|
||||||
frames: &[i16],
|
frames: &[i16],
|
||||||
) -> DataCallbackResult {
|
) -> DataCallbackResult {
|
||||||
let _ = catch_unwind(AssertUnwindSafe(|| match self.state.try_lock() {
|
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||||
Ok(mut s) => s.ingest_i16(frames),
|
if let Ok(mut s) = self.state.lock() {
|
||||||
Err(std::sync::TryLockError::WouldBlock) => {
|
s.ingest_i16(frames);
|
||||||
self.audio_processing_stats.increment_callback_xrun();
|
|
||||||
}
|
|
||||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
|
||||||
warn!(target: "chanora_audio", "android: capture state poisoned: {e}");
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
DataCallbackResult::Continue
|
DataCallbackResult::Continue
|
||||||
@@ -497,7 +522,8 @@ impl AudioInputCallback for InputCallback {
|
|||||||
// writes stereo f32 directly to the Oboe output buffer.
|
// writes stereo f32 directly to the Oboe output buffer.
|
||||||
|
|
||||||
struct OutputCallback {
|
struct OutputCallback {
|
||||||
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
|
handler: AudioHandler<SessionAudioId>,
|
||||||
|
event_queue: Arc<AudioEventQueue>,
|
||||||
output_gain: Arc<AtomicU32>,
|
output_gain: Arc<AtomicU32>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
event_tx: BackendEventTx,
|
event_tx: BackendEventTx,
|
||||||
@@ -516,39 +542,49 @@ impl AudioOutputCallback for OutputCallback {
|
|||||||
frames: &mut [(f32, f32)],
|
frames: &mut [(f32, f32)],
|
||||||
) -> DataCallbackResult {
|
) -> DataCallbackResult {
|
||||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||||
self.pcm_consumer.drain_stereo_into_zero_filling(frames);
|
let buf: &mut [f32] = unsafe {
|
||||||
|
std::slice::from_raw_parts_mut(frames.as_mut_ptr() as *mut f32, frames.len() * 2)
|
||||||
|
};
|
||||||
|
for s in buf.iter_mut() {
|
||||||
|
*s = 0.0;
|
||||||
|
}
|
||||||
|
let consumer = AudioEventQueue::consumer(&self.event_queue);
|
||||||
|
for cmd in consumer.drain_controls() {
|
||||||
|
match cmd {
|
||||||
|
AudioCommand::SetVolume(id, vol) => {
|
||||||
|
if let Some(q) = self.handler.get_mut_queues().get_mut(&id) {
|
||||||
|
q.volume = vol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioCommand::RemoveClient(id) => {
|
||||||
|
self.handler.get_mut_queues().remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pkt in consumer.drain_packets(50) {
|
||||||
|
if let Err(e) = self.handler.handle_packet(pkt.client_id, pkt.data) {
|
||||||
|
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = self.handler.fill_buffer(buf);
|
||||||
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
||||||
let muted = self.output_muted.load(Ordering::Relaxed);
|
let muted = self.output_muted.load(Ordering::Relaxed);
|
||||||
if muted {
|
if muted {
|
||||||
for frame in frames.iter_mut() {
|
for s in buf.iter_mut() {
|
||||||
*frame = (0.0, 0.0);
|
*s = 0.0;
|
||||||
}
|
}
|
||||||
} else if gain != 1.0 {
|
} else if gain != 1.0 {
|
||||||
for (left, right) in frames.iter_mut() {
|
for s in buf.iter_mut() {
|
||||||
*left *= gain;
|
*s *= gain;
|
||||||
*right *= gain;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut sum_squares = 0.0_f32;
|
|
||||||
for (left, right) in frames.iter() {
|
|
||||||
sum_squares += left * left + right * right;
|
|
||||||
}
|
|
||||||
let sample_count = frames.len() * 2;
|
|
||||||
let dbfs = if sample_count == 0 {
|
|
||||||
-120.0
|
|
||||||
} else {
|
|
||||||
let rms = (sum_squares / sample_count as f32).sqrt();
|
|
||||||
if rms <= 0.000_001 {
|
|
||||||
-120.0
|
|
||||||
} else {
|
|
||||||
20.0 * rms.log10()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
self.audio_processing_stats
|
self.audio_processing_stats
|
||||||
.update_render(dbfs, frames.len() as u32);
|
.update_render(crate::frame::dbfs(buf), frames.len() as u32);
|
||||||
|
|
||||||
for (left, right) in frames.iter() {
|
for chunk in buf.chunks_exact(2) {
|
||||||
self.pending_render_ref[self.pending_render_ref_len] = (left + right) * 0.5;
|
self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 0.5;
|
||||||
self.pending_render_ref_len += 1;
|
self.pending_render_ref_len += 1;
|
||||||
if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||||
self.render_reference.write(&self.pending_render_ref);
|
self.render_reference.write(&self.pending_render_ref);
|
||||||
@@ -580,7 +616,6 @@ impl AudioOutputCallback for OutputCallback {
|
|||||||
pub struct AndroidVoiceUnit {
|
pub struct AndroidVoiceUnit {
|
||||||
input: Option<AudioStreamAsync<OboeInput, InputCallback>>,
|
input: Option<AudioStreamAsync<OboeInput, InputCallback>>,
|
||||||
output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,
|
output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,
|
||||||
render_producer_shutdown: Arc<AtomicBool>,
|
|
||||||
|
|
||||||
// Recorded achieved values (SDD-112).
|
// Recorded achieved values (SDD-112).
|
||||||
input_perf: AchievedPerformanceMode,
|
input_perf: AchievedPerformanceMode,
|
||||||
@@ -602,13 +637,11 @@ pub struct AndroidVoiceUnit {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct HardwareEffectHandles {
|
struct HardwareEffectHandles {
|
||||||
aec: Option<AndroidGlobalObject>,
|
aec: Option<jni::objects::GlobalRef>,
|
||||||
ns: Option<AndroidGlobalObject>,
|
ns: Option<jni::objects::GlobalRef>,
|
||||||
agc: Option<AndroidGlobalObject>,
|
agc: Option<jni::objects::GlobalRef>,
|
||||||
}
|
}
|
||||||
|
|
||||||
type AndroidGlobalObject = jni::refs::Global<jni::objects::JObject<'static>>;
|
|
||||||
|
|
||||||
impl AndroidVoiceUnit {
|
impl AndroidVoiceUnit {
|
||||||
/// Open the input + output streams (SDD-111 + SDD-112) and,
|
/// Open the input + output streams (SDD-111 + SDD-112) and,
|
||||||
/// once a session id is available, attach SDD-113 hardware
|
/// once a session id is available, attach SDD-113 hardware
|
||||||
@@ -675,7 +708,6 @@ impl AndroidVoiceUnit {
|
|||||||
|
|
||||||
let input_cb = InputCallback {
|
let input_cb = InputCallback {
|
||||||
state: capture_state.clone(),
|
state: capture_state.clone(),
|
||||||
audio_processing_stats: audio_processing_stats.clone(),
|
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
};
|
};
|
||||||
let input_builder = input_builder.set_callback(input_cb);
|
let input_builder = input_builder.set_callback(input_cb);
|
||||||
@@ -692,12 +724,7 @@ impl AndroidVoiceUnit {
|
|||||||
error = ?e,
|
error = ?e,
|
||||||
"android: primary input stream open failed; entering fallback ladder"
|
"android: primary input stream open failed; entering fallback ladder"
|
||||||
);
|
);
|
||||||
match Self::open_input_fallback(
|
match Self::open_input_fallback(cfg, &event_tx, capture_state.clone()) {
|
||||||
cfg,
|
|
||||||
&event_tx,
|
|
||||||
capture_state.clone(),
|
|
||||||
audio_processing_stats.clone(),
|
|
||||||
) {
|
|
||||||
Ok(s) => Some(s),
|
Ok(s) => Some(s),
|
||||||
Err(fallback_err) => {
|
Err(fallback_err) => {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -764,10 +791,9 @@ impl AndroidVoiceUnit {
|
|||||||
|
|
||||||
let render_ref_for_output = render_ref_buf.clone();
|
let render_ref_for_output = render_ref_buf.clone();
|
||||||
let event_queue = params.event_producer.queue();
|
let event_queue = params.event_producer.queue();
|
||||||
let render_ring =
|
|
||||||
crate::android_render_ring::AndroidRenderRing::new(ANDROID_RENDER_RING_CAPACITY);
|
|
||||||
let output_cb = OutputCallback {
|
let output_cb = OutputCallback {
|
||||||
pcm_consumer: render_ring.consumer(),
|
handler: params.handler,
|
||||||
|
event_queue: event_queue.clone(),
|
||||||
output_gain: params.output_gain.clone(),
|
output_gain: params.output_gain.clone(),
|
||||||
output_muted: params.output_muted.clone(),
|
output_muted: params.output_muted.clone(),
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
@@ -789,7 +815,8 @@ impl AndroidVoiceUnit {
|
|||||||
Self::open_output_fallback(
|
Self::open_output_fallback(
|
||||||
cfg,
|
cfg,
|
||||||
&event_tx,
|
&event_tx,
|
||||||
render_ring.consumer(),
|
AudioHandler::new(),
|
||||||
|
event_queue.clone(),
|
||||||
params.output_gain.clone(),
|
params.output_gain.clone(),
|
||||||
params.output_muted.clone(),
|
params.output_muted.clone(),
|
||||||
audio_processing_stats.clone(),
|
audio_processing_stats.clone(),
|
||||||
@@ -797,11 +824,6 @@ impl AndroidVoiceUnit {
|
|||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let render_producer_shutdown = Self::spawn_render_producer(
|
|
||||||
params.handler,
|
|
||||||
AudioEventQueue::consumer(&event_queue),
|
|
||||||
render_ring.producer(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let output_frames_per_burst = output_stream.get_frames_per_burst();
|
let output_frames_per_burst = output_stream.get_frames_per_burst();
|
||||||
if output_frames_per_burst > 0 {
|
if output_frames_per_burst > 0 {
|
||||||
@@ -958,7 +980,6 @@ impl AndroidVoiceUnit {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
input: input_stream,
|
input: input_stream,
|
||||||
output: Some(output_stream),
|
output: Some(output_stream),
|
||||||
render_producer_shutdown,
|
|
||||||
input_perf,
|
input_perf,
|
||||||
input_share,
|
input_share,
|
||||||
output_perf,
|
output_perf,
|
||||||
@@ -976,7 +997,6 @@ impl AndroidVoiceUnit {
|
|||||||
cfg: &AndroidVoiceStreamConfig,
|
cfg: &AndroidVoiceStreamConfig,
|
||||||
event_tx: &BackendEventTx,
|
event_tx: &BackendEventTx,
|
||||||
capture_state: Arc<Mutex<AndroidCaptureState>>,
|
capture_state: Arc<Mutex<AndroidCaptureState>>,
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
|
||||||
) -> Result<AudioStreamAsync<OboeInput, InputCallback>, BackendError> {
|
) -> Result<AudioStreamAsync<OboeInput, InputCallback>, BackendError> {
|
||||||
// SDD-112 items 6 & 7: explore (preset × sharing) independently
|
// SDD-112 items 6 & 7: explore (preset × sharing) independently
|
||||||
// via the pure helpers in `mobile_voice_backend`. Primary
|
// via the pure helpers in `mobile_voice_backend`. Primary
|
||||||
@@ -1013,7 +1033,6 @@ impl AndroidVoiceUnit {
|
|||||||
};
|
};
|
||||||
let cb = InputCallback {
|
let cb = InputCallback {
|
||||||
state: capture_state.clone(),
|
state: capture_state.clone(),
|
||||||
audio_processing_stats: audio_processing_stats.clone(),
|
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
};
|
};
|
||||||
let builder = AudioStreamBuilder::default()
|
let builder = AudioStreamBuilder::default()
|
||||||
@@ -1049,14 +1068,16 @@ impl AndroidVoiceUnit {
|
|||||||
fn open_output_fallback(
|
fn open_output_fallback(
|
||||||
cfg: &AndroidVoiceStreamConfig,
|
cfg: &AndroidVoiceStreamConfig,
|
||||||
event_tx: &BackendEventTx,
|
event_tx: &BackendEventTx,
|
||||||
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
|
handler: AudioHandler<SessionAudioId>,
|
||||||
|
event_queue: Arc<AudioEventQueue>,
|
||||||
output_gain: Arc<AtomicU32>,
|
output_gain: Arc<AtomicU32>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
render_reference: Arc<RenderReferenceBuffer>,
|
render_reference: Arc<RenderReferenceBuffer>,
|
||||||
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
||||||
let cb = OutputCallback {
|
let cb = OutputCallback {
|
||||||
pcm_consumer,
|
handler,
|
||||||
|
event_queue,
|
||||||
output_gain,
|
output_gain,
|
||||||
output_muted,
|
output_muted,
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
@@ -1081,50 +1102,6 @@ impl AndroidVoiceUnit {
|
|||||||
.map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
|
.map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_render_producer(
|
|
||||||
mut handler: AudioHandler<SessionAudioId>,
|
|
||||||
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
|
||||||
pcm_producer: crate::android_render_ring::AndroidRenderRingProducer,
|
|
||||||
) -> Arc<AtomicBool> {
|
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
|
||||||
let shutdown_for_task = shutdown.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut pull_scratch = vec![0.0_f32; ANDROID_RENDER_PULL_SAMPLES];
|
|
||||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
|
||||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
if shutdown_for_task.load(Ordering::Relaxed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
for cmd in event_consumer.drain_controls() {
|
|
||||||
match cmd {
|
|
||||||
AudioCommand::SetVolume(id, vol) => {
|
|
||||||
if let Some(q) = handler.get_mut_queues().get_mut(&id) {
|
|
||||||
q.volume = vol;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AudioCommand::RemoveClient(id) => {
|
|
||||||
handler.get_mut_queues().remove(&id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for pkt in event_consumer.drain_packets(50) {
|
|
||||||
if let Err(e) = handler.handle_packet(pkt.client_id, pkt.data) {
|
|
||||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pull_scratch.fill(0.0);
|
|
||||||
let _ = handler.fill_buffer(&mut pull_scratch);
|
|
||||||
pcm_producer.push_frame_lossy(&pull_scratch);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
shutdown
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clone of the event sender, for JNI focus / SCO listeners
|
/// Clone of the event sender, for JNI focus / SCO listeners
|
||||||
/// registered on the engine's behalf.
|
/// registered on the engine's behalf.
|
||||||
pub fn event_sender(&self) -> BackendEventTx {
|
pub fn event_sender(&self) -> BackendEventTx {
|
||||||
@@ -1176,7 +1153,6 @@ impl MobileVoiceAudioBackend for AndroidVoiceUnit {
|
|||||||
fn close(&mut self) -> Result<(), BackendError> {
|
fn close(&mut self) -> Result<(), BackendError> {
|
||||||
// SDD-115 reverse order: release hardware effects FIRST,
|
// SDD-115 reverse order: release hardware effects FIRST,
|
||||||
// then close streams.
|
// then close streams.
|
||||||
self.render_producer_shutdown.store(true, Ordering::Relaxed);
|
|
||||||
release_hardware_effects(&mut self.hw_effects);
|
release_hardware_effects(&mut self.hw_effects);
|
||||||
self.stop().ok();
|
self.stop().ok();
|
||||||
// Dropping the Option drops the underlying AudioStreamAsync
|
// Dropping the Option drops the underlying AudioStreamAsync
|
||||||
@@ -1234,7 +1210,6 @@ impl Drop for AndroidVoiceUnit {
|
|||||||
// Wrap in catch_unwind so a panic during Drop cannot unwind
|
// Wrap in catch_unwind so a panic during Drop cannot unwind
|
||||||
// into the JVM (SDD-115 callback safety).
|
// into the JVM (SDD-115 callback safety).
|
||||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||||
self.render_producer_shutdown.store(true, Ordering::Relaxed);
|
|
||||||
release_hardware_effects(&mut self.hw_effects);
|
release_hardware_effects(&mut self.hw_effects);
|
||||||
// SDD-116: clear the diagnostics slot on Drop too.
|
// SDD-116: clear the diagnostics slot on Drop too.
|
||||||
clear_android_audio_diagnostics();
|
clear_android_audio_diagnostics();
|
||||||
@@ -1314,71 +1289,62 @@ fn attach_hardware_effects_inner(
|
|||||||
session_id: AudioSessionId,
|
session_id: AudioSessionId,
|
||||||
effects: &crate::AudioEffects,
|
effects: &crate::AudioEffects,
|
||||||
) -> HardwareEffectHandles {
|
) -> HardwareEffectHandles {
|
||||||
with_android_env("hardware effects", |env| {
|
|
||||||
let mut handles = HardwareEffectHandles::default();
|
|
||||||
if effects.aec {
|
|
||||||
handles.aec = create_effect(
|
|
||||||
env,
|
|
||||||
"android/media/audiofx/AcousticEchoCanceler",
|
|
||||||
session_id,
|
|
||||||
"AEC",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if effects.noise_suppression {
|
|
||||||
handles.ns = create_effect(
|
|
||||||
env,
|
|
||||||
"android/media/audiofx/NoiseSuppressor",
|
|
||||||
session_id,
|
|
||||||
"NS",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if effects.agc {
|
|
||||||
handles.agc = create_effect(
|
|
||||||
env,
|
|
||||||
"android/media/audiofx/AutomaticGainControl",
|
|
||||||
session_id,
|
|
||||||
"AGC",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
handles
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_android_env<R>(
|
|
||||||
operation: &str,
|
|
||||||
op: impl for<'local> FnOnce(&mut jni::Env<'local>) -> R,
|
|
||||||
) -> Option<R> {
|
|
||||||
let ctx = ndk_context::android_context();
|
let ctx = ndk_context::android_context();
|
||||||
if ctx.vm().is_null() {
|
if ctx.vm().is_null() {
|
||||||
warn!(
|
warn!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
operation,
|
"android: ndk_context vm null; cannot bind hardware effects (software fallback engages)"
|
||||||
"android: ndk_context vm null; JNI call skipped"
|
|
||||||
);
|
);
|
||||||
return None;
|
return HardwareEffectHandles::default();
|
||||||
}
|
}
|
||||||
|
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||||
let jvm = unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) };
|
Ok(v) => v,
|
||||||
match jvm.attach_current_thread(|env| Ok::<R, jni::errors::Error>(op(env))) {
|
|
||||||
Ok(value) => Some(value),
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(target: "chanora_audio", error = %e, operation, "android: attach_current_thread failed");
|
warn!(target: "chanora_audio", error = %e, "android: JavaVM::from_raw failed; effects not bound");
|
||||||
None
|
return HardwareEffectHandles::default();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
let mut env = match jvm.attach_current_thread() {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(target: "chanora_audio", error = %e, "android: attach_current_thread failed; effects not bound");
|
||||||
|
return HardwareEffectHandles::default();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut handles = HardwareEffectHandles::default();
|
||||||
|
if effects.aec {
|
||||||
|
handles.aec = create_effect(
|
||||||
|
&mut env,
|
||||||
|
"android/media/audiofx/AcousticEchoCanceler",
|
||||||
|
session_id,
|
||||||
|
"AEC",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
if effects.noise_suppression {
|
||||||
|
handles.ns = create_effect(
|
||||||
|
&mut env,
|
||||||
|
"android/media/audiofx/NoiseSuppressor",
|
||||||
|
session_id,
|
||||||
|
"NS",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if effects.agc {
|
||||||
|
handles.agc = create_effect(
|
||||||
|
&mut env,
|
||||||
|
"android/media/audiofx/AutomaticGainControl",
|
||||||
|
session_id,
|
||||||
|
"AGC",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
handles
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SDD-113 item 3: probe the static `isAvailable()` on each effect
|
/// SDD-113 item 3: probe the static `isAvailable()` on each effect
|
||||||
/// class before calling `create(int)`. Returns `false` on any JNI
|
/// class before calling `create(int)`. Returns `false` on any JNI
|
||||||
/// failure so the caller engages the software fallback.
|
/// failure so the caller engages the software fallback.
|
||||||
fn effect_is_available(env: &mut jni::Env<'_>, class: &jni::objects::JClass, label: &str) -> bool {
|
fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, label: &str) -> bool {
|
||||||
match env.call_static_method(
|
match env.call_static_method(class, "isAvailable", "()Z", &[]) {
|
||||||
class,
|
|
||||||
jni::jni_str!("isAvailable"),
|
|
||||||
jni::jni_sig!("()Z"),
|
|
||||||
&[],
|
|
||||||
) {
|
|
||||||
Ok(v) => match v.z() {
|
Ok(v) => match v.z() {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1396,14 +1362,14 @@ fn effect_is_available(env: &mut jni::Env<'_>, class: &jni::objects::JClass, lab
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn create_effect(
|
fn create_effect(
|
||||||
env: &mut jni::Env<'_>,
|
env: &mut jni::JNIEnv,
|
||||||
fqcn: &str,
|
fqcn: &str,
|
||||||
session_id: AudioSessionId,
|
session_id: AudioSessionId,
|
||||||
label: &str,
|
label: &str,
|
||||||
) -> Option<AndroidGlobalObject> {
|
) -> Option<jni::objects::GlobalRef> {
|
||||||
use jni::objects::JValue;
|
use jni::objects::JValue;
|
||||||
// Class.create(int) -> ClassInstance|null
|
// Class.create(int) -> ClassInstance|null
|
||||||
let class = match env.find_class(jni::strings::JNIString::new(fqcn)) {
|
let class = match env.find_class(fqcn) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
|
warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
|
||||||
@@ -1419,18 +1385,10 @@ fn create_effect(
|
|||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let create_sig = match jni::signature::RuntimeMethodSignature::from_str(format!("(I)L{fqcn};"))
|
|
||||||
{
|
|
||||||
Ok(sig) => sig,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(target: "chanora_audio", error = %e, effect = label, "android: create() signature parse failed");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let inst = match env.call_static_method(
|
let inst = match env.call_static_method(
|
||||||
&class,
|
&class,
|
||||||
jni::jni_str!("create"),
|
"create",
|
||||||
create_sig.method_signature(),
|
&format!("(I)L{fqcn};"),
|
||||||
&[JValue::Int(session_id)],
|
&[JValue::Int(session_id)],
|
||||||
) {
|
) {
|
||||||
Ok(v) => match v.l() {
|
Ok(v) => match v.l() {
|
||||||
@@ -1455,8 +1413,8 @@ fn create_effect(
|
|||||||
// setEnabled(true) -> int (success code)
|
// setEnabled(true) -> int (success code)
|
||||||
if let Err(e) = env.call_method(
|
if let Err(e) = env.call_method(
|
||||||
&inst,
|
&inst,
|
||||||
jni::jni_str!("setEnabled"),
|
"setEnabled",
|
||||||
jni::jni_sig!("(Z)I"),
|
"(Z)I",
|
||||||
&[JValue::Bool(jni::sys::JNI_TRUE)],
|
&[JValue::Bool(jni::sys::JNI_TRUE)],
|
||||||
) {
|
) {
|
||||||
let _ = env.exception_clear();
|
let _ = env.exception_clear();
|
||||||
@@ -1492,28 +1450,34 @@ fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) {
|
|||||||
if aec.is_none() && ns.is_none() && agc.is_none() {
|
if aec.is_none() && ns.is_none() && agc.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = with_android_env("release hardware effects", |env| {
|
let ctx = ndk_context::android_context();
|
||||||
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
if ctx.vm().is_null() {
|
||||||
if let Some(g) = effect {
|
return;
|
||||||
let _ = env.call_method(
|
}
|
||||||
g.as_obj(),
|
// SAFETY: vm is non-null and owned for process lifetime via JNI_OnLoad.
|
||||||
jni::jni_str!("setEnabled"),
|
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||||
jni::jni_sig!("(Z)I"),
|
Ok(v) => v,
|
||||||
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
Err(_) => return,
|
||||||
);
|
};
|
||||||
env.exception_clear();
|
let mut env = match jvm.attach_current_thread() {
|
||||||
let _ = env.call_method(
|
Ok(e) => e,
|
||||||
g.as_obj(),
|
Err(_) => return,
|
||||||
jni::jni_str!("release"),
|
};
|
||||||
jni::jni_sig!("()V"),
|
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
||||||
&[],
|
if let Some(g) = effect {
|
||||||
);
|
let _ = env.call_method(
|
||||||
env.exception_clear();
|
g.as_obj(),
|
||||||
drop(g);
|
"setEnabled",
|
||||||
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
|
"(Z)I",
|
||||||
}
|
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
||||||
|
);
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
let _ = env.call_method(g.as_obj(), "release", "()V", &[]);
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
drop(g);
|
||||||
|
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Process-global BackendEvent sender for JNI callbacks --------
|
// --- Process-global BackendEvent sender for JNI callbacks --------
|
||||||
@@ -1588,7 +1552,7 @@ pub fn chanora_android_stop_voice_service() -> bool {
|
|||||||
fn call_voice_service_static(method: &str) -> bool {
|
fn call_voice_service_static(method: &str) -> bool {
|
||||||
use jni::objects::{JObject, JValue};
|
use jni::objects::{JObject, JValue};
|
||||||
let ctx = ndk_context::android_context();
|
let ctx = ndk_context::android_context();
|
||||||
if ctx.context().is_null() {
|
if ctx.vm().is_null() || ctx.context().is_null() {
|
||||||
warn!(
|
warn!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
method,
|
method,
|
||||||
@@ -1596,41 +1560,54 @@ fn call_voice_service_static(method: &str) -> bool {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// SAFETY: vm/context populated by chanora_bridge::android_init at
|
||||||
with_android_env("voice foreground service", |env| {
|
// JNI_OnLoad + initChanoraContext; both pointers are valid for
|
||||||
// SAFETY: ndk_context::context() is the application Context
|
// the process lifetime.
|
||||||
// jobject; valid global ref for process lifetime.
|
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||||
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
Ok(v) => v,
|
||||||
let class = match load_app_class(env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
Err(e) => {
|
||||||
Some(c) => c,
|
warn!(target: "chanora_audio", error = %e, method, "android: JavaVM::from_raw failed");
|
||||||
None => return false,
|
return false;
|
||||||
};
|
|
||||||
match env.call_static_method(
|
|
||||||
&class,
|
|
||||||
jni::strings::JNIString::new(method),
|
|
||||||
jni::jni_sig!("(Landroid/content/Context;)V"),
|
|
||||||
&[JValue::Object(&context_obj)],
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
|
||||||
info!(target: "chanora_audio", method, "android: voice foreground service call dispatched");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
env.exception_clear();
|
|
||||||
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
.unwrap_or(false)
|
let mut env = match jvm.attach_current_thread() {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(target: "chanora_audio", error = %e, method, "android: attach_current_thread failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// SAFETY: ndk_context::context() is the application Context
|
||||||
|
// jobject; valid global ref for process lifetime.
|
||||||
|
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||||
|
let class = match load_app_class(&mut env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
match env.call_static_method(
|
||||||
|
&class,
|
||||||
|
method,
|
||||||
|
"(Landroid/content/Context;)V",
|
||||||
|
&[JValue::Object(&context_obj)],
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
info!(target: "chanora_audio", method, "android: voice foreground service call dispatched");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_app_class<'local>(
|
fn load_app_class<'local>(
|
||||||
env: &mut jni::Env<'local>,
|
env: &mut jni::JNIEnv<'local>,
|
||||||
context_obj: &jni::objects::JObject<'local>,
|
context_obj: &jni::objects::JObject<'local>,
|
||||||
slash_name: &str,
|
slash_name: &str,
|
||||||
) -> Option<jni::objects::JClass<'local>> {
|
) -> Option<jni::objects::JClass<'local>> {
|
||||||
match env.find_class(jni::strings::JNIString::new(slash_name)) {
|
match env.find_class(slash_name) {
|
||||||
Ok(c) => return Some(c),
|
Ok(c) => return Some(c),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = env.exception_clear();
|
let _ = env.exception_clear();
|
||||||
@@ -1641,8 +1618,8 @@ fn load_app_class<'local>(
|
|||||||
let loader = match env
|
let loader = match env
|
||||||
.call_method(
|
.call_method(
|
||||||
context_obj,
|
context_obj,
|
||||||
jni::jni_str!("getClassLoader"),
|
"getClassLoader",
|
||||||
jni::jni_sig!("()Ljava/lang/ClassLoader;"),
|
"()Ljava/lang/ClassLoader;",
|
||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
.and_then(|v| v.l())
|
.and_then(|v| v.l())
|
||||||
@@ -1667,20 +1644,13 @@ fn load_app_class<'local>(
|
|||||||
match env
|
match env
|
||||||
.call_method(
|
.call_method(
|
||||||
&loader,
|
&loader,
|
||||||
jni::jni_str!("loadClass"),
|
"loadClass",
|
||||||
jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Class;"),
|
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||||
&[jni::objects::JValue::Object(&class_name_obj)],
|
&[jni::objects::JValue::Object(&class_name_obj)],
|
||||||
)
|
)
|
||||||
.and_then(|v| v.l())
|
.and_then(|v| v.l())
|
||||||
{
|
{
|
||||||
Ok(class_obj) => match env.cast_local::<jni::objects::JClass>(class_obj) {
|
Ok(class_obj) => Some(jni::objects::JClass::from(class_obj)),
|
||||||
Ok(class) => Some(class),
|
|
||||||
Err(e) => {
|
|
||||||
env.exception_clear();
|
|
||||||
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass returned non-Class object");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = env.exception_clear();
|
let _ = env.exception_clear();
|
||||||
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed");
|
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed");
|
||||||
@@ -1711,7 +1681,7 @@ fn load_app_class<'local>(
|
|||||||
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange<
|
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange<
|
||||||
'local,
|
'local,
|
||||||
>(
|
>(
|
||||||
_env: jni::EnvUnowned<'local>,
|
_env: jni::JNIEnv<'local>,
|
||||||
_class: jni::objects::JClass<'local>,
|
_class: jni::objects::JClass<'local>,
|
||||||
state: jni::sys::jint,
|
state: jni::sys::jint,
|
||||||
) {
|
) {
|
||||||
@@ -1749,7 +1719,7 @@ pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusContro
|
|||||||
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange<
|
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange<
|
||||||
'local,
|
'local,
|
||||||
>(
|
>(
|
||||||
_env: jni::EnvUnowned<'local>,
|
_env: jni::JNIEnv<'local>,
|
||||||
_class: jni::objects::JClass<'local>,
|
_class: jni::objects::JClass<'local>,
|
||||||
state: jni::sys::jint,
|
state: jni::sys::jint,
|
||||||
) {
|
) {
|
||||||
@@ -1798,7 +1768,7 @@ pub fn chanora_android_stop_bluetooth_sco() -> bool {
|
|||||||
fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
||||||
use jni::objects::{JObject, JValue};
|
use jni::objects::{JObject, JValue};
|
||||||
let ctx = ndk_context::android_context();
|
let ctx = ndk_context::android_context();
|
||||||
if ctx.context().is_null() {
|
if ctx.vm().is_null() || ctx.context().is_null() {
|
||||||
warn!(
|
warn!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
class = fqcn,
|
class = fqcn,
|
||||||
@@ -1807,29 +1777,39 @@ fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||||
with_android_env("static context call", |env| {
|
Ok(v) => v,
|
||||||
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
Err(e) => {
|
||||||
let class = match load_app_class(env, &context_obj, fqcn) {
|
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: JavaVM::from_raw failed");
|
||||||
Some(c) => c,
|
return false;
|
||||||
None => return false,
|
|
||||||
};
|
|
||||||
match env.call_static_method(
|
|
||||||
&class,
|
|
||||||
jni::strings::JNIString::new(method),
|
|
||||||
jni::jni_sig!("(Landroid/content/Context;)V"),
|
|
||||||
&[JValue::Object(&context_obj)],
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
|
||||||
info!(target: "chanora_audio", class = fqcn, method, "android: dispatched");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
env.exception_clear();
|
|
||||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
.unwrap_or(false)
|
let mut env = match jvm.attach_current_thread() {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: attach_current_thread failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||||
|
let class = match load_app_class(&mut env, &context_obj, fqcn) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
match env.call_static_method(
|
||||||
|
&class,
|
||||||
|
method,
|
||||||
|
"(Landroid/content/Context;)V",
|
||||||
|
&[JValue::Object(&context_obj)],
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
info!(target: "chanora_audio", class = fqcn, method, "android: dispatched");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const PACKET_QUEUE_CAPACITY: usize = 100;
|
|||||||
const CONTROL_QUEUE_CAPACITY: usize = 32;
|
const CONTROL_QUEUE_CAPACITY: usize = 32;
|
||||||
|
|
||||||
/// A raw inbound voice packet waiting to be inserted into AudioHandler.
|
/// A raw inbound voice packet waiting to be inserted into AudioHandler.
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct AudioPacket {
|
pub struct AudioPacket {
|
||||||
/// Client whose TeamSpeak audio packet this belongs to.
|
/// Client whose TeamSpeak audio packet this belongs to.
|
||||||
pub client_id: SessionAudioId,
|
pub client_id: SessionAudioId,
|
||||||
@@ -19,14 +18,10 @@ pub struct AudioPacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Control commands from the main thread to the audio callback.
|
/// Control commands from the main thread to the audio callback.
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum AudioCommand {
|
pub enum AudioCommand {
|
||||||
/// Set a client's output volume.
|
/// Set a client's output volume.
|
||||||
SetVolume(SessionAudioId, f32),
|
SetVolume(SessionAudioId, f32),
|
||||||
/// Remove a client's decode queue.
|
/// Remove a client's decode queue.
|
||||||
// TODO: Wire to client disconnect path; handled in callback but no
|
|
||||||
// producer currently pushes this command.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
RemoveClient(SessionAudioId),
|
RemoveClient(SessionAudioId),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,64 +116,3 @@ impl AudioEventConsumer {
|
|||||||
std::iter::from_fn(move || self.queue.control_queue.pop())
|
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,19 +404,6 @@ impl Default for SharedAudioProcessingStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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.
|
/// Store capture levels and VAD state.
|
||||||
pub fn update_capture(
|
pub fn update_capture(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
pub(crate) fn append_processed_i16_bounded(
|
|
||||||
pcm_accum: &mut Vec<i16>,
|
|
||||||
frame: &[f32],
|
|
||||||
gain: f32,
|
|
||||||
) -> bool {
|
|
||||||
if (gain - 1.0).abs() < f32::EPSILON {
|
|
||||||
for src in frame.iter().copied() {
|
|
||||||
if pcm_accum.len() == pcm_accum.capacity() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
pcm_accum.push(crate::frame::f32_to_i16(src));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for src in frame.iter().copied() {
|
|
||||||
if pcm_accum.len() == pcm_accum.capacity() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let scaled = (crate::frame::f32_to_i16(src) as f32) * gain;
|
|
||||||
pcm_accum.push(scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn append_i16_bounded(pcm_accum: &mut Vec<i16>, frame: &[i16]) -> bool {
|
|
||||||
for src in frame.iter().copied() {
|
|
||||||
if pcm_accum.len() == pcm_accum.capacity() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
pcm_accum.push(src);
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{append_i16_bounded, append_processed_i16_bounded};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn append_processed_i16_bounded_does_not_grow_when_full() {
|
|
||||||
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
|
|
||||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
|
|
||||||
let warmed_capacity = accum.capacity();
|
|
||||||
let warmed_ptr = accum.as_ptr();
|
|
||||||
|
|
||||||
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
|
|
||||||
|
|
||||||
assert!(dropped);
|
|
||||||
assert_eq!(accum.len(), warmed_capacity);
|
|
||||||
assert_eq!(accum.capacity(), warmed_capacity);
|
|
||||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn append_processed_i16_bounded_preserves_expected_10ms_append() {
|
|
||||||
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
|
|
||||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
|
|
||||||
let warmed_capacity = accum.capacity();
|
|
||||||
let warmed_ptr = accum.as_ptr();
|
|
||||||
|
|
||||||
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
|
|
||||||
|
|
||||||
assert!(!dropped);
|
|
||||||
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
|
|
||||||
assert_eq!(accum.capacity(), warmed_capacity);
|
|
||||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn append_i16_bounded_does_not_grow_when_preroll_exceeds_capacity() {
|
|
||||||
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
|
||||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
|
|
||||||
let warmed_capacity = accum.capacity();
|
|
||||||
let warmed_ptr = accum.as_ptr();
|
|
||||||
|
|
||||||
let dropped = append_i16_bounded(&mut accum, &frame);
|
|
||||||
|
|
||||||
assert!(dropped);
|
|
||||||
assert_eq!(accum.len(), warmed_capacity);
|
|
||||||
assert_eq!(accum.capacity(), warmed_capacity);
|
|
||||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn append_i16_bounded_preserves_expected_10ms_append() {
|
|
||||||
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
|
||||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
|
|
||||||
let warmed_capacity = accum.capacity();
|
|
||||||
let warmed_ptr = accum.as_ptr();
|
|
||||||
|
|
||||||
let dropped = append_i16_bounded(&mut accum, &frame);
|
|
||||||
|
|
||||||
assert!(!dropped);
|
|
||||||
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
|
|
||||||
assert_eq!(accum.capacity(), warmed_capacity);
|
|
||||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn append_i16_bounded_preserves_full_vad_preroll_window() {
|
|
||||||
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
|
||||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES * 16);
|
|
||||||
let warmed_capacity = accum.capacity();
|
|
||||||
let warmed_ptr = accum.as_ptr();
|
|
||||||
|
|
||||||
for _ in 0..16 {
|
|
||||||
assert!(!append_i16_bounded(&mut accum, &frame));
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES * 16);
|
|
||||||
assert_eq!(accum.capacity(), warmed_capacity);
|
|
||||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
|
||||||
assert!(append_i16_bounded(&mut accum, &frame));
|
|
||||||
assert_eq!(accum.len(), warmed_capacity);
|
|
||||||
assert_eq!(accum.capacity(), warmed_capacity);
|
|
||||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
pub(crate) struct CaptureResampleResult {
|
|
||||||
pub(crate) output_len: usize,
|
|
||||||
pub(crate) dropped: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resample_capture_to_48k(
|
|
||||||
samples: &[i16],
|
|
||||||
input_sample_rate_hz: u32,
|
|
||||||
resample_pos: &mut f64,
|
|
||||||
resample_last: &mut i16,
|
|
||||||
scratch: &mut Vec<i16>,
|
|
||||||
) -> CaptureResampleResult {
|
|
||||||
scratch.clear();
|
|
||||||
if samples.is_empty() {
|
|
||||||
return CaptureResampleResult {
|
|
||||||
output_len: 0,
|
|
||||||
dropped: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let ratio = input_sample_rate_hz.max(1) as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
|
|
||||||
let mut pos = *resample_pos;
|
|
||||||
let mut dropped = false;
|
|
||||||
while pos < samples.len() as f64 {
|
|
||||||
let i = pos.floor() as isize;
|
|
||||||
let frac = pos - i as f64;
|
|
||||||
let a = if i <= 0 {
|
|
||||||
*resample_last as f64
|
|
||||||
} else {
|
|
||||||
samples[(i - 1) as usize] as f64
|
|
||||||
};
|
|
||||||
let b = if i < samples.len() as isize {
|
|
||||||
samples[i as usize] as f64
|
|
||||||
} else {
|
|
||||||
a
|
|
||||||
};
|
|
||||||
let value = (a + frac * (b - a))
|
|
||||||
.round()
|
|
||||||
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
|
|
||||||
if scratch.len() < scratch.capacity() {
|
|
||||||
scratch.push(value);
|
|
||||||
} else {
|
|
||||||
dropped = true;
|
|
||||||
}
|
|
||||||
pos += ratio;
|
|
||||||
}
|
|
||||||
*resample_pos = pos - samples.len() as f64;
|
|
||||||
*resample_last = *samples.last().unwrap_or(resample_last);
|
|
||||||
CaptureResampleResult {
|
|
||||||
output_len: scratch.len(),
|
|
||||||
dropped,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod android_voice_unit_resampler_tests {
|
|
||||||
use super::resample_capture_to_48k;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn android_voice_unit_resampler_reuses_scratch_without_capacity_growth() {
|
|
||||||
let samples: Vec<i16> = (0..882).map(|i| i as i16).collect();
|
|
||||||
let mut pos = 0.0;
|
|
||||||
let mut last = 0_i16;
|
|
||||||
let mut scratch = Vec::with_capacity(960);
|
|
||||||
|
|
||||||
let first = resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
|
|
||||||
let first_len = first.output_len;
|
|
||||||
assert_eq!(first_len, 960);
|
|
||||||
assert!(!first.dropped);
|
|
||||||
assert_eq!(scratch.len(), first_len);
|
|
||||||
let warmed_capacity = scratch.capacity();
|
|
||||||
let warmed_ptr = scratch.as_ptr();
|
|
||||||
|
|
||||||
for _ in 0..8 {
|
|
||||||
let result =
|
|
||||||
resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
|
|
||||||
let len = result.output_len;
|
|
||||||
assert_eq!(len, scratch.len());
|
|
||||||
assert!(len >= 959 && len <= 960);
|
|
||||||
assert!(!result.dropped);
|
|
||||||
assert_eq!(scratch.capacity(), warmed_capacity);
|
|
||||||
assert_eq!(scratch.as_ptr(), warmed_ptr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn android_voice_unit_resampler_truncates_oversized_burst_without_capacity_growth() {
|
|
||||||
let samples: Vec<i16> = (0..4_800).map(|i| i as i16).collect();
|
|
||||||
let mut pos = 0.0;
|
|
||||||
let mut last = 0_i16;
|
|
||||||
let mut scratch = Vec::with_capacity(960);
|
|
||||||
let warmed_capacity = scratch.capacity();
|
|
||||||
let warmed_ptr = scratch.as_ptr();
|
|
||||||
|
|
||||||
let result = resample_capture_to_48k(&samples, 48_000, &mut pos, &mut last, &mut scratch);
|
|
||||||
|
|
||||||
assert_eq!(result.output_len, warmed_capacity);
|
|
||||||
assert!(result.dropped);
|
|
||||||
assert_eq!(scratch.len(), warmed_capacity);
|
|
||||||
assert_eq!(scratch.capacity(), warmed_capacity);
|
|
||||||
assert_eq!(scratch.as_ptr(), warmed_ptr);
|
|
||||||
assert_eq!(pos, 0.0);
|
|
||||||
assert_eq!(last, *samples.last().unwrap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -314,15 +314,6 @@ mod tests {
|
|||||||
rec.stop();
|
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]
|
#[test]
|
||||||
fn wav_header_is_44_bytes() {
|
fn wav_header_is_44_bytes() {
|
||||||
// Write to a temp file to test the header.
|
// Write to a temp file to test the header.
|
||||||
|
|||||||
@@ -838,7 +838,6 @@ impl AudioEngine {
|
|||||||
transmit_flag_for_capture,
|
transmit_flag_for_capture,
|
||||||
frames_sent.clone(),
|
frames_sent.clone(),
|
||||||
cfg.mic_gain,
|
cfg.mic_gain,
|
||||||
audio_processing_stats.clone(),
|
|
||||||
);
|
);
|
||||||
let (input_stream, capture_active) = match capture_result {
|
let (input_stream, capture_active) = match capture_result {
|
||||||
Ok(s) => (Some(s), true),
|
Ok(s) => (Some(s), true),
|
||||||
@@ -981,6 +980,7 @@ impl AudioEngine {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
transmit_gate,
|
transmit_gate,
|
||||||
|
frames_sent,
|
||||||
frames_received,
|
frames_received,
|
||||||
output_gain,
|
output_gain,
|
||||||
output_muted,
|
output_muted,
|
||||||
@@ -1084,25 +1084,11 @@ impl AudioEngine {
|
|||||||
audio_processing_stats: audio_processing_stats.clone(),
|
audio_processing_stats: audio_processing_stats.clone(),
|
||||||
};
|
};
|
||||||
let mut android_voice_unit =
|
let mut android_voice_unit =
|
||||||
match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params) {
|
crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params).map_err(|e| {
|
||||||
Ok(unit) => unit,
|
AudioError::Backend(format!("android: failed to open Oboe voice unit: {e}"))
|
||||||
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(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!(
|
return Err(AudioError::Backend(format!(
|
||||||
"android: failed to start Oboe voice unit: {e}"
|
"android: failed to start Oboe voice unit: {e}"
|
||||||
)));
|
)));
|
||||||
@@ -1259,7 +1245,6 @@ impl AudioEngine {
|
|||||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::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>>> =
|
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||||
Arc::new(Mutex::new(AudioHandler::new()));
|
Arc::new(Mutex::new(AudioHandler::new()));
|
||||||
let voice_out_tx_for_backend = voice_out_tx.clone();
|
let voice_out_tx_for_backend = voice_out_tx.clone();
|
||||||
@@ -1292,8 +1277,8 @@ impl AudioEngine {
|
|||||||
// VPIO render callback (commit 4) finds decoded frames
|
// VPIO render callback (commit 4) finds decoded frames
|
||||||
// waiting.
|
// waiting.
|
||||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||||
let frames_received_for_task = frames_received.clone();
|
|
||||||
let handler_for_task = audio_handler.clone();
|
let handler_for_task = audio_handler.clone();
|
||||||
|
let frames_received_for_task = frames_received.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -1306,15 +1291,10 @@ impl AudioEngine {
|
|||||||
Some(v) => {
|
Some(v) => {
|
||||||
let id = SessionAudioId(v.from_client);
|
let id = SessionAudioId(v.from_client);
|
||||||
let mut h = handler_for_task.lock().unwrap();
|
let mut h = handler_for_task.lock().unwrap();
|
||||||
let res = h.handle_packet(id, v.packet);
|
if let Err(e) = h.handle_packet(id, v.packet) {
|
||||||
drop(h);
|
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||||
match res {
|
} else {
|
||||||
Ok(_) => {
|
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
||||||
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => break,
|
None => break,
|
||||||
@@ -1339,43 +1319,6 @@ impl AudioEngine {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
fn rollback_android_startup_resources(audio_mode_stack: &mut crate::mode_stack::ModeStack) {
|
|
||||||
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
|
|
||||||
crate::android_voice_unit::chanora_android_abandon_audio_focus();
|
|
||||||
match release_android_audio_mode_for_startup_rollback(audio_mode_stack) {
|
|
||||||
crate::mode_stack::ModeRelease::LastRelease { prior } => {
|
|
||||||
match android_set_audio_mode(prior) {
|
|
||||||
Ok(()) => info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
restored_mode = prior,
|
|
||||||
"android: AudioManager mode restored during startup rollback"
|
|
||||||
),
|
|
||||||
Err(e) => warn!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
error = %e,
|
|
||||||
restored_mode = prior,
|
|
||||||
"android: failed to restore AudioManager mode during startup rollback"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crate::mode_stack::ModeRelease::StillHeld => {
|
|
||||||
info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
"android: audio mode still held during startup rollback"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
crate::mode_stack::ModeRelease::AlreadyReleased => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if crate::android_voice_unit::chanora_android_stop_voice_service() {
|
|
||||||
info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
"android: voice foreground service stopped during startup rollback"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop the engine. Idempotent.
|
/// Stop the engine. Idempotent.
|
||||||
pub fn stop(&mut self) {
|
pub fn stop(&mut self) {
|
||||||
if let Some(tx) = self.shutdown_tx.take() {
|
if let Some(tx) = self.shutdown_tx.take() {
|
||||||
@@ -1654,11 +1597,6 @@ impl AudioEngine {
|
|||||||
self.frames_received.load(Ordering::Relaxed)
|
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.
|
/// Current audio-processing config snapshot.
|
||||||
pub fn audio_processing_config_snapshot(&self) -> crate::AudioProcessingConfig {
|
pub fn audio_processing_config_snapshot(&self) -> crate::AudioProcessingConfig {
|
||||||
self.audio_processing_config.lock().unwrap().clone()
|
self.audio_processing_config.lock().unwrap().clone()
|
||||||
@@ -1726,21 +1664,15 @@ impl AudioEngine {
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{
|
||||||
let mut cmd = AudioCommand::SetVolume(SessionAudioId(client_id), clamped);
|
let mut cmd = AudioCommand::SetVolume(SessionAudioId(client_id), clamped);
|
||||||
for _ in 0..64 {
|
loop {
|
||||||
match self.audio_event_producer.push_control(cmd) {
|
match self.audio_event_producer.push_control(cmd) {
|
||||||
Ok(()) => return,
|
Ok(()) => break,
|
||||||
Err(returned) => {
|
Err(returned) => {
|
||||||
cmd = returned;
|
cmd = returned;
|
||||||
std::thread::yield_now();
|
std::thread::yield_now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
{
|
{
|
||||||
@@ -1770,33 +1702,6 @@ impl Drop for AudioEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(test, target_os = "android"))]
|
|
||||||
fn release_android_audio_mode_for_startup_rollback(
|
|
||||||
audio_mode_stack: &mut crate::mode_stack::ModeStack,
|
|
||||||
) -> crate::mode_stack::ModeRelease {
|
|
||||||
audio_mode_stack.release()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn android_startup_rollback_releases_acquired_mode_snapshot() {
|
|
||||||
let mut stack = crate::mode_stack::ModeStack::new();
|
|
||||||
let _ = stack.acquire(7);
|
|
||||||
|
|
||||||
let release = release_android_audio_mode_for_startup_rollback(&mut stack);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
release,
|
|
||||||
crate::mode_stack::ModeRelease::LastRelease { prior: 7 }
|
|
||||||
);
|
|
||||||
assert_eq!(stack.refcount(), 0);
|
|
||||||
assert_eq!(stack.snapshot(), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Capture pipeline ----------
|
// ---------- Capture pipeline ----------
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
@@ -1806,7 +1711,6 @@ fn try_open_capture(
|
|||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
frames_sent: Arc<AtomicU32>,
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
|
||||||
) -> Result<cpal::Stream, AudioError> {
|
) -> Result<cpal::Stream, AudioError> {
|
||||||
let in_cfg = in_dev
|
let in_cfg = in_dev
|
||||||
.default_input_config()
|
.default_input_config()
|
||||||
@@ -1839,13 +1743,9 @@ fn try_open_capture(
|
|||||||
in_sample_rate,
|
in_sample_rate,
|
||||||
in_channels,
|
in_channels,
|
||||||
mic_gain,
|
mic_gain,
|
||||||
crate::opus_voice::start_out_packet_worker(
|
voice_out_tx,
|
||||||
voice_out_tx,
|
|
||||||
frames_sent.clone(),
|
|
||||||
"cpal-capture",
|
|
||||||
)?,
|
|
||||||
transmit_active,
|
transmit_active,
|
||||||
audio_processing_stats,
|
frames_sent,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
let stream = match in_format {
|
let stream = match in_format {
|
||||||
@@ -1879,10 +1779,11 @@ struct CaptureState {
|
|||||||
/// Linux ALSA defaults).
|
/// Linux ALSA defaults).
|
||||||
resample_last: f32,
|
resample_last: f32,
|
||||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
/// The PTT transmission gate. Read once per outbound frame; the
|
/// The PTT transmission gate. Read once per outbound frame; the
|
||||||
/// CaptureState never mutates this flag.
|
/// CaptureState never mutates this flag.
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
/// Pre-allocated mono downmix buffer. Resized in-place each
|
/// Pre-allocated mono downmix buffer. Resized in-place each
|
||||||
/// callback; `clear()` retains capacity. SDD-094 realtime-thread
|
/// callback; `clear()` retains capacity. SDD-094 realtime-thread
|
||||||
/// invariant: this avoids the heap allocation that the prior fix
|
/// invariant: this avoids the heap allocation that the prior fix
|
||||||
@@ -1896,27 +1797,8 @@ struct CaptureState {
|
|||||||
/// capacity so the drain-into-frame path skips the allocator
|
/// capacity so the drain-into-frame path skips the allocator
|
||||||
/// after warmup. Same precedent as `mono_scratch` above.
|
/// after warmup. Same precedent as `mono_scratch` above.
|
||||||
frame_scratch: Vec<f32>,
|
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")))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
impl CaptureState {
|
impl CaptureState {
|
||||||
fn new(
|
fn new(
|
||||||
@@ -1924,9 +1806,9 @@ impl CaptureState {
|
|||||||
in_sample_rate: u32,
|
in_sample_rate: u32,
|
||||||
in_channels: usize,
|
in_channels: usize,
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
frames_sent: Arc<AtomicU32>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
encoder,
|
encoder,
|
||||||
@@ -1939,13 +1821,13 @@ impl CaptureState {
|
|||||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx,
|
voice_out_tx,
|
||||||
transmit_active,
|
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),
|
mono_scratch: Vec::with_capacity(4096),
|
||||||
|
// Exact upper bound: drain pulls FRAME_SAMPLES at a time.
|
||||||
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
|
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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1953,8 +1835,17 @@ impl CaptureState {
|
|||||||
/// 48 kHz mono frames; encode and send when `transmit_active`
|
/// 48 kHz mono frames; encode and send when `transmit_active`
|
||||||
/// is true (PTT engaged).
|
/// is true (PTT engaged).
|
||||||
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
|
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
|
||||||
// 1. Down-mix to mono (pre-gain). Always performed so the level
|
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||||
// meter reflects real mic input even when PTT is released.
|
// 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.
|
||||||
let in_channels = self.in_channels;
|
let in_channels = self.in_channels;
|
||||||
let mic_gain = self.mic_gain;
|
let mic_gain = self.mic_gain;
|
||||||
self.mono_scratch.clear();
|
self.mono_scratch.clear();
|
||||||
@@ -1962,28 +1853,8 @@ impl CaptureState {
|
|||||||
self.mono_scratch.reserve(frame_count);
|
self.mono_scratch.reserve(frame_count);
|
||||||
for frame in buf.chunks(in_channels) {
|
for frame in buf.chunks(in_channels) {
|
||||||
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
|
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
|
||||||
self.mono_scratch.push(sum / frame.len() as f32);
|
self.mono_scratch
|
||||||
}
|
.push((sum / frame.len() as f32) * mic_gain);
|
||||||
|
|
||||||
// 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
|
// 2. Resample to 48 kHz if needed. We re-borrow
|
||||||
@@ -2035,6 +1906,7 @@ impl CaptureState {
|
|||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
crate::opus_voice::send_voip_frame(
|
crate::opus_voice::send_voip_frame(
|
||||||
&self.voice_out_tx,
|
&self.voice_out_tx,
|
||||||
|
&self.frames_sent,
|
||||||
&self.opus_out,
|
&self.opus_out,
|
||||||
len,
|
len,
|
||||||
|| {
|
|| {
|
||||||
@@ -2448,13 +2320,6 @@ impl std::fmt::Display for AudioModeError {
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
impl std::error::Error for AudioModeError {}
|
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
|
/// JNI helper shared by `android_get_audio_mode` and
|
||||||
/// `android_set_audio_mode`: attach to the current thread and return
|
/// `android_set_audio_mode`: attach to the current thread and return
|
||||||
/// the `AudioManager` jobject. Centralised so SDD-108's two platform
|
/// the `AudioManager` jobject. Centralised so SDD-108's two platform
|
||||||
@@ -2462,10 +2327,7 @@ impl From<jni::errors::Error> for AudioModeError {
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
fn android_audio_manager_call<F, R>(op: F) -> Result<R, AudioModeError>
|
fn android_audio_manager_call<F, R>(op: F) -> Result<R, AudioModeError>
|
||||||
where
|
where
|
||||||
F: for<'local> FnOnce(
|
F: FnOnce(&mut jni::JNIEnv, &jni::objects::JObject) -> Result<R, AudioModeError>
|
||||||
&mut jni::Env<'local>,
|
|
||||||
&jni::objects::JObject<'local>,
|
|
||||||
) -> Result<R, AudioModeError>
|
|
||||||
+ std::panic::UnwindSafe,
|
+ std::panic::UnwindSafe,
|
||||||
{
|
{
|
||||||
use jni::objects::{JObject, JString, JValue};
|
use jni::objects::{JObject, JString, JValue};
|
||||||
@@ -2482,40 +2344,42 @@ where
|
|||||||
// at a live JavaVM* set by our bridge_init JNI hook. The
|
// at a live JavaVM* set by our bridge_init JNI hook. The
|
||||||
// unsafe block contains only the cast required by
|
// unsafe block contains only the cast required by
|
||||||
// `JavaVM::from_raw`.
|
// `JavaVM::from_raw`.
|
||||||
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) };
|
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) }
|
||||||
jvm.attach_current_thread(|env| -> Result<R, AudioModeError> {
|
.map_err(|e| AudioModeError::JniAttachFailed(format!("jvm from_raw: {e}")))?;
|
||||||
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
let mut env = jvm
|
||||||
|
.attach_current_thread()
|
||||||
|
.map_err(|e| AudioModeError::JniAttachFailed(format!("attach: {e}")))?;
|
||||||
|
|
||||||
let service_name: JString =
|
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||||
env.new_string("audio")
|
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
let service_name: JString =
|
||||||
method: "new_string",
|
env.new_string("audio")
|
||||||
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 {
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
method: "getSystemService",
|
method: "new_string",
|
||||||
detail: e.to_string(),
|
detail: e.to_string(),
|
||||||
})?
|
|
||||||
.l()
|
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
|
||||||
method: "getSystemService",
|
|
||||||
detail: format!("obj cast: {e}"),
|
|
||||||
})?;
|
})?;
|
||||||
if audio_manager.is_null() {
|
let audio_manager = env
|
||||||
return Err(AudioModeError::Other(
|
.call_method(
|
||||||
"AudioManager service is null".to_string(),
|
&context_obj,
|
||||||
));
|
"getSystemService",
|
||||||
}
|
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||||
op(env, &audio_manager)
|
&[JValue::Object(&service_name.into())],
|
||||||
})
|
)
|
||||||
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
|
method: "getSystemService",
|
||||||
|
detail: e.to_string(),
|
||||||
|
})?
|
||||||
|
.l()
|
||||||
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
|
method: "getSystemService",
|
||||||
|
detail: format!("obj cast: {e}"),
|
||||||
|
})?;
|
||||||
|
if audio_manager.is_null() {
|
||||||
|
return Err(AudioModeError::Other(
|
||||||
|
"AudioManager service is null".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
op(&mut env, &audio_manager)
|
||||||
});
|
});
|
||||||
match result {
|
match result {
|
||||||
Ok(inner) => inner,
|
Ok(inner) => inner,
|
||||||
@@ -2534,21 +2398,16 @@ where
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
|
pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
|
||||||
android_audio_manager_call(|env, audio_manager| {
|
android_audio_manager_call(|env, audio_manager| {
|
||||||
env.call_method(
|
env.call_method(audio_manager, "getMode", "()I", &[])
|
||||||
audio_manager,
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
jni::jni_str!("getMode"),
|
method: "getMode",
|
||||||
jni::jni_sig!("()I"),
|
detail: e.to_string(),
|
||||||
&[],
|
})?
|
||||||
)
|
.i()
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
method: "getMode",
|
method: "getMode",
|
||||||
detail: e.to_string(),
|
detail: format!("int cast: {e}"),
|
||||||
})?
|
})
|
||||||
.i()
|
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
|
||||||
method: "getMode",
|
|
||||||
detail: format!("int cast: {e}"),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2562,16 +2421,11 @@ pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
|
|||||||
pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
|
pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
|
||||||
use jni::objects::JValue;
|
use jni::objects::JValue;
|
||||||
android_audio_manager_call(move |env, audio_manager| {
|
android_audio_manager_call(move |env, audio_manager| {
|
||||||
env.call_method(
|
env.call_method(audio_manager, "setMode", "(I)V", &[JValue::Int(mode)])
|
||||||
audio_manager,
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
jni::jni_str!("setMode"),
|
method: "setMode",
|
||||||
jni::jni_sig!("(I)V"),
|
detail: e.to_string(),
|
||||||
&[JValue::Int(mode)],
|
})?;
|
||||||
)
|
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
|
||||||
method: "setMode",
|
|
||||||
detail: e.to_string(),
|
|
||||||
})?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2628,7 +2482,6 @@ pub mod bench_seam {
|
|||||||
tx,
|
tx,
|
||||||
transmit_active.clone(),
|
transmit_active.clone(),
|
||||||
frames_sent,
|
frames_sent,
|
||||||
Arc::new(crate::SharedAudioProcessingStats::default()),
|
|
||||||
);
|
);
|
||||||
Self {
|
Self {
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ mod inner {
|
|||||||
use coreaudio::audio_unit::render_callback::{self, data};
|
use coreaudio::audio_unit::render_callback::{self, data};
|
||||||
use coreaudio::audio_unit::IOType;
|
use coreaudio::audio_unit::IOType;
|
||||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::mobile_voice_backend::VoiceAudioParams;
|
use crate::mobile_voice_backend::VoiceAudioParams;
|
||||||
use crate::processor::AudioProcessor;
|
use crate::processor::AudioProcessor;
|
||||||
use crate::AudioError;
|
use crate::AudioError;
|
||||||
|
use chanora_protocol::OutPacket;
|
||||||
|
|
||||||
const SAMPLE_RATE_HZ: f64 = 48_000.0;
|
const SAMPLE_RATE_HZ: f64 = 48_000.0;
|
||||||
|
|
||||||
@@ -61,10 +63,43 @@ mod inner {
|
|||||||
/// If the capture callback runs before the render callback has written
|
/// If the capture callback runs before the render callback has written
|
||||||
/// a frame it reads zeros (silence reference), which is safe — Sonora
|
/// a frame it reads zeros (silence reference), which is safe — Sonora
|
||||||
/// AEC3 simply skips cancellation for that frame.
|
/// AEC3 simply skips cancellation for that frame.
|
||||||
type RenderReferenceBuffer = crate::render_reference::RenderReferenceBuffer<480, 4>;
|
struct RenderReferenceBuffer {
|
||||||
type RenderReferenceFrameAccumulator =
|
buf: Box<[[f32; 480]; 4]>,
|
||||||
crate::render_reference::RenderReferenceFrameAccumulator<480>;
|
write_idx: std::sync::atomic::AtomicUsize,
|
||||||
const RAW_RENDER_SCRATCH_FRAMES: usize = 1024;
|
}
|
||||||
|
|
||||||
|
impl RenderReferenceBuffer {
|
||||||
|
fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
buf: Box::new([[0.0; 480]; 4]),
|
||||||
|
write_idx: std::sync::atomic::AtomicUsize::new(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write one 10 ms render-reference frame. Realtime-safe.
|
||||||
|
fn write(&self, frame: &[f32; 480]) {
|
||||||
|
let idx = self.write_idx.load(Ordering::Relaxed);
|
||||||
|
// SAFETY: only one writer (render callback); torn reads
|
||||||
|
// are bounded to one frame of AEC degradation.
|
||||||
|
unsafe {
|
||||||
|
let slot = &self.buf[idx] as *const [f32; 480] as *mut [f32; 480];
|
||||||
|
(*slot).copy_from_slice(frame);
|
||||||
|
}
|
||||||
|
self.write_idx.store((idx + 1) % 4, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the most recently completed render-reference frame.
|
||||||
|
fn read_latest(&self) -> [f32; 480] {
|
||||||
|
let wi = self.write_idx.load(Ordering::Relaxed);
|
||||||
|
let ri = (wi + 3) % 4;
|
||||||
|
self.buf[ri]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: accessed from two audio callback threads; data races are
|
||||||
|
// bounded to one frame of AEC quality degradation.
|
||||||
|
unsafe impl Send for RenderReferenceBuffer {}
|
||||||
|
unsafe impl Sync for RenderReferenceBuffer {}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
// Capture pipeline state //
|
// Capture pipeline state //
|
||||||
@@ -74,9 +109,10 @@ mod inner {
|
|||||||
encoder: OpusEncoder,
|
encoder: OpusEncoder,
|
||||||
pcm_accum: Vec<i16>,
|
pcm_accum: Vec<i16>,
|
||||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||||
@@ -92,6 +128,7 @@ mod inner {
|
|||||||
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
|
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
pending_10ms_len: usize,
|
pending_10ms_len: usize,
|
||||||
fallback_warned_backend: Option<crate::VadBackend>,
|
fallback_warned_backend: Option<crate::VadBackend>,
|
||||||
|
wav_recorder: Option<Arc<crate::debug_wav::WavDebugRecorder>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RawCaptureState {
|
impl RawCaptureState {
|
||||||
@@ -109,13 +146,10 @@ mod inner {
|
|||||||
encoder,
|
encoder,
|
||||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
voice_out_tx: params.voice_out_tx.clone(),
|
||||||
params.voice_out_tx.clone(),
|
|
||||||
params.frames_sent.clone(),
|
|
||||||
"ios-raw",
|
|
||||||
)?,
|
|
||||||
transmit_active: params.transmit_active.clone(),
|
transmit_active: params.transmit_active.clone(),
|
||||||
output_muted: params.output_muted.clone(),
|
output_muted: params.output_muted.clone(),
|
||||||
|
frames_sent: params.frames_sent.clone(),
|
||||||
mic_gain: params.mic_gain,
|
mic_gain: params.mic_gain,
|
||||||
voice_activity_selector: params.voice_activity_selector.clone(),
|
voice_activity_selector: params.voice_activity_selector.clone(),
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||||
@@ -132,6 +166,7 @@ mod inner {
|
|||||||
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
pending_10ms_len: 0,
|
pending_10ms_len: 0,
|
||||||
fallback_warned_backend: None,
|
fallback_warned_backend: None,
|
||||||
|
wav_recorder: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +204,6 @@ mod inner {
|
|||||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||||
let frame = self.pending_10ms;
|
let frame = self.pending_10ms;
|
||||||
self.process_10ms_capture_frame(&frame);
|
self.process_10ms_capture_frame(&frame);
|
||||||
self.encode_complete_20ms_frames();
|
|
||||||
self.pending_10ms_len = 0;
|
self.pending_10ms_len = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,10 +213,7 @@ mod inner {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.encode_complete_20ms_frames();
|
// Encode complete 20 ms Opus frames.
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_complete_20ms_frames(&mut self) {
|
|
||||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||||
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
||||||
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
||||||
@@ -192,6 +223,7 @@ mod inner {
|
|||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
crate::opus_voice::send_voip_frame(
|
crate::opus_voice::send_voip_frame(
|
||||||
&self.voice_out_tx,
|
&self.voice_out_tx,
|
||||||
|
&self.frames_sent,
|
||||||
&self.opus_out,
|
&self.opus_out,
|
||||||
len,
|
len,
|
||||||
|| {
|
|| {
|
||||||
@@ -221,18 +253,20 @@ mod inner {
|
|||||||
}
|
}
|
||||||
let input_dbfs = crate::frame::dbfs(&frame);
|
let input_dbfs = crate::frame::dbfs(&frame);
|
||||||
|
|
||||||
// Debug WAV mic taps are intentionally unavailable on iOS raw
|
// WAV tap: raw mic (before processing).
|
||||||
// realtime callbacks until WavDebugRecorder supports a
|
if let Some(ref rec) = self.wav_recorder {
|
||||||
// preallocated handoff path; the current recorder push path
|
rec.push_raw_mic(&frame);
|
||||||
// allocates per frame.
|
}
|
||||||
|
|
||||||
// Feed render reference to WebRTC APM before capture so AEC can adapt.
|
// Feed render reference to WebRTC APM before capture so AEC can adapt.
|
||||||
let render_ref = self.render_reference.read_latest();
|
let render_ref = self.render_reference.read_latest();
|
||||||
self.webrtc_apm_processor.process_render(&render_ref);
|
self.webrtc_apm_processor.process_render(&render_ref);
|
||||||
self.webrtc_apm_processor.process_capture(&mut frame);
|
self.webrtc_apm_processor.process_capture(&mut frame);
|
||||||
|
|
||||||
// Processed-mic debug WAV capture is disabled for the same
|
// WAV tap: processed mic (after WebRTC APM).
|
||||||
// realtime allocation reason as the raw-mic tap above.
|
if let Some(ref rec) = self.wav_recorder {
|
||||||
|
rec.push_processed_mic(&frame);
|
||||||
|
}
|
||||||
|
|
||||||
let voice_activity_mode = self
|
let voice_activity_mode = self
|
||||||
.voice_activity_selector
|
.voice_activity_selector
|
||||||
@@ -266,9 +300,14 @@ mod inner {
|
|||||||
self.current_vad_backend = vad_backend;
|
self.current_vad_backend = vad_backend;
|
||||||
self.fallback_warned_backend = None;
|
self.fallback_warned_backend = None;
|
||||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
if vad_backend == crate::VadBackend::SileroOnnx {
|
||||||
self.silero_coreml_worker = None;
|
self.silero_coreml_worker =
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
|
||||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
if self.silero_coreml_worker.is_none() {
|
||||||
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||||
|
} else {
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.silero_coreml_worker = None;
|
self.silero_coreml_worker = None;
|
||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
@@ -286,38 +325,20 @@ mod inner {
|
|||||||
speech: true,
|
speech: true,
|
||||||
}
|
}
|
||||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||||
match crate::vad::callback_vad_worker_policy(
|
if let Some(worker) = self.silero_coreml_worker.as_ref() {
|
||||||
voice_activity_mode,
|
let enqueued = worker.try_send(capture_seq, &frame);
|
||||||
vad_backend,
|
if !worker.is_stale(capture_seq) {
|
||||||
self.silero_coreml_worker.is_some(),
|
let p = worker.latest_probability();
|
||||||
) {
|
crate::vad::VadOutput {
|
||||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
probability: p,
|
||||||
let worker = self
|
speech: p >= 0.5,
|
||||||
.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::VadWorkerPolicy::UseFallback => {
|
crate::vad::VadOutput {
|
||||||
|
probability: 0.0,
|
||||||
|
speech: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
used_fallback_vad = true;
|
used_fallback_vad = true;
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
self.mark_vad_fallback_active(vad_backend);
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
@@ -325,10 +346,13 @@ mod inner {
|
|||||||
&frame,
|
&frame,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
} else {
|
||||||
probability: 1.0,
|
used_fallback_vad = true;
|
||||||
speech: true,
|
self.mark_vad_fallback_active(vad_backend);
|
||||||
},
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
|
&mut self.vad_detector,
|
||||||
|
&frame,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
@@ -354,12 +378,15 @@ mod inner {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if crate::capture_accumulator::append_processed_i16_bounded(
|
let gain = self.mic_gain;
|
||||||
&mut self.pcm_accum,
|
if (gain - 1.0).abs() < f32::EPSILON {
|
||||||
&frame,
|
self.pcm_accum
|
||||||
self.mic_gain,
|
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
||||||
) {
|
} else {
|
||||||
self.audio_processing_stats.increment_callback_xrun();
|
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
||||||
|
let scaled = crate::frame::f32_to_i16(s) as f32 * gain;
|
||||||
|
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -419,9 +446,7 @@ mod inner {
|
|||||||
})
|
})
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
|
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
|
||||||
|
|
||||||
let mut scratch = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES * 2];
|
let mut scratch: Vec<f32> = Vec::with_capacity(2048);
|
||||||
let mut mono = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES];
|
|
||||||
let mut render_ref_accum = RenderReferenceFrameAccumulator::new();
|
|
||||||
let handler = params.handler.clone();
|
let handler = params.handler.clone();
|
||||||
let output_gain = params.output_gain.clone();
|
let output_gain = params.output_gain.clone();
|
||||||
let output_muted = params.output_muted.clone();
|
let output_muted = params.output_muted.clone();
|
||||||
@@ -430,10 +455,9 @@ mod inner {
|
|||||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
let out = args.data.buffer;
|
let out = args.data.buffer;
|
||||||
let n = out.len();
|
let n = out.len();
|
||||||
let process_n = n.min(RAW_RENDER_SCRATCH_FRAMES);
|
let stereo_n = n * 2;
|
||||||
let stereo_n = process_n * 2;
|
if scratch.len() < stereo_n {
|
||||||
if n > RAW_RENDER_SCRATCH_FRAMES {
|
scratch.resize(stereo_n, 0.0);
|
||||||
stats_render.increment_callback_xrun();
|
|
||||||
}
|
}
|
||||||
scratch[..stereo_n].fill(0.0);
|
scratch[..stereo_n].fill(0.0);
|
||||||
|
|
||||||
@@ -451,26 +475,22 @@ mod inner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// INV_012: copy render reference BEFORE playout.
|
// 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(
|
crate::voice_render::downmix_stereo_f32_to_mono_f32(
|
||||||
&scratch[..stereo_n],
|
&scratch[..stereo_n],
|
||||||
&mut mono[..process_n],
|
&mut ref_frame[..mono_n],
|
||||||
);
|
);
|
||||||
render_ref_accum.push_mono_samples(&mono[..process_n], |frame| {
|
render_ref_buf.write(&ref_frame);
|
||||||
render_ref_buf.write(frame);
|
|
||||||
});
|
|
||||||
|
|
||||||
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
||||||
let muted = output_muted.load(Ordering::Relaxed);
|
let muted = output_muted.load(Ordering::Relaxed);
|
||||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
||||||
&scratch[..stereo_n],
|
&scratch[..stereo_n],
|
||||||
&mut out[..process_n],
|
out,
|
||||||
1,
|
|
||||||
gain,
|
gain,
|
||||||
muted,
|
muted,
|
||||||
);
|
);
|
||||||
if process_n < n {
|
|
||||||
out[process_n..].fill(0);
|
|
||||||
}
|
|
||||||
if mix_stats.clipped_samples > 0 {
|
if mix_stats.clipped_samples > 0 {
|
||||||
stats_render.add_clipped_samples(mix_stats.clipped_samples);
|
stats_render.add_clipped_samples(mix_stats.clipped_samples);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
//! * AVAudioSession category / mode configuration — Swift owns the
|
//! * AVAudioSession category / mode configuration — Swift owns the
|
||||||
//! session (it must be set up before Flutter loads).
|
//! session (it must be set up before Flutter loads).
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use audiopus::coder::Encoder as OpusEncoder;
|
use audiopus::coder::Encoder as OpusEncoder;
|
||||||
@@ -74,11 +74,12 @@ use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
|||||||
use coreaudio::audio_unit::render_callback::{self, data};
|
use coreaudio::audio_unit::render_callback::{self, data};
|
||||||
use coreaudio::audio_unit::IOType;
|
use coreaudio::audio_unit::IOType;
|
||||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
||||||
use crossbeam::queue::ArrayQueue;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::mobile_voice_backend::VoiceAudioParams;
|
use crate::mobile_voice_backend::VoiceAudioParams;
|
||||||
use crate::AudioError;
|
use crate::AudioError;
|
||||||
|
use chanora_protocol::OutPacket;
|
||||||
|
|
||||||
/// Sample rate every layer above us assumes. Matches the Opus
|
/// Sample rate every layer above us assumes. Matches the Opus
|
||||||
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
|
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
|
||||||
@@ -103,15 +104,6 @@ const INPUT_BUS: Element = Element::Input;
|
|||||||
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
|
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
|
||||||
const PRE_ROLL_FRAMES: usize = 16;
|
const PRE_ROLL_FRAMES: usize = 16;
|
||||||
|
|
||||||
/// Enough room for the 160 ms VAD pre-roll plus a few jitter frames, without
|
|
||||||
/// growing inside the input callback.
|
|
||||||
const CAPTURE_ACCUM_CAPACITY_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES * 20;
|
|
||||||
|
|
||||||
/// Fixed iOS render scratch capacity. Larger callback requests are truncated
|
|
||||||
/// to this capacity and the remaining output is silence.
|
|
||||||
#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
|
|
||||||
const IOS_RENDER_SCRATCH_FRAMES: usize = 4096;
|
|
||||||
|
|
||||||
/// Capture pipeline state owned by the VPIO input callback. The
|
/// Capture pipeline state owned by the VPIO input callback. The
|
||||||
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
|
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
|
||||||
/// downmix or resample needed — VPIO's hardware-side mix-down
|
/// downmix or resample needed — VPIO's hardware-side mix-down
|
||||||
@@ -139,9 +131,10 @@ struct IosCaptureState {
|
|||||||
/// jitter without reallocating.
|
/// jitter without reallocating.
|
||||||
pcm_accum: Vec<i16>,
|
pcm_accum: Vec<i16>,
|
||||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||||
@@ -160,6 +153,7 @@ struct IosCaptureState {
|
|||||||
pre_roll_count: usize,
|
pre_roll_count: usize,
|
||||||
pre_roll_flushed: bool,
|
pre_roll_flushed: bool,
|
||||||
capture_frame_seq: u64,
|
capture_frame_seq: u64,
|
||||||
|
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IosCaptureState {
|
impl IosCaptureState {
|
||||||
@@ -167,20 +161,20 @@ impl IosCaptureState {
|
|||||||
/// Encoder configuration is the same as cpal-side
|
/// Encoder configuration is the same as cpal-side
|
||||||
/// `try_open_capture` (engine.rs) so audio quality is platform-
|
/// `try_open_capture` (engine.rs) so audio quality is platform-
|
||||||
/// neutral.
|
/// neutral.
|
||||||
fn new(params: &VoiceAudioParams) -> Result<Self, AudioError> {
|
fn new(
|
||||||
|
params: &VoiceAudioParams,
|
||||||
|
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
||||||
|
) -> Result<Self, AudioError> {
|
||||||
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
|
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
encoder,
|
encoder,
|
||||||
pcm_accum: Vec::with_capacity(CAPTURE_ACCUM_CAPACITY_SAMPLES),
|
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
voice_out_tx: params.voice_out_tx.clone(),
|
||||||
params.voice_out_tx.clone(),
|
|
||||||
params.frames_sent.clone(),
|
|
||||||
"ios-vpio",
|
|
||||||
)?,
|
|
||||||
transmit_active: params.transmit_active.clone(),
|
transmit_active: params.transmit_active.clone(),
|
||||||
output_muted: params.output_muted.clone(),
|
output_muted: params.output_muted.clone(),
|
||||||
|
frames_sent: params.frames_sent.clone(),
|
||||||
mic_gain: params.mic_gain,
|
mic_gain: params.mic_gain,
|
||||||
voice_activity_selector: params.voice_activity_selector.clone(),
|
voice_activity_selector: params.voice_activity_selector.clone(),
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||||
@@ -198,6 +192,7 @@ impl IosCaptureState {
|
|||||||
pre_roll_count: 0,
|
pre_roll_count: 0,
|
||||||
pre_roll_flushed: false,
|
pre_roll_flushed: false,
|
||||||
capture_frame_seq: 0,
|
capture_frame_seq: 0,
|
||||||
|
wav_recorder,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +266,7 @@ impl IosCaptureState {
|
|||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
crate::opus_voice::send_voip_frame(
|
crate::opus_voice::send_voip_frame(
|
||||||
&self.voice_out_tx,
|
&self.voice_out_tx,
|
||||||
|
&self.frames_sent,
|
||||||
&self.opus_out,
|
&self.opus_out,
|
||||||
len,
|
len,
|
||||||
|| {
|
|| {
|
||||||
@@ -301,9 +297,25 @@ impl IosCaptureState {
|
|||||||
}
|
}
|
||||||
let input_dbfs = crate::frame::dbfs(&frame);
|
let input_dbfs = crate::frame::dbfs(&frame);
|
||||||
|
|
||||||
|
// WAV tap: raw mic (before processing, DIAG_002).
|
||||||
|
if let Ok(guard) = self.wav_recorder.try_lock() {
|
||||||
|
if let Some(rec) = guard.as_ref() {
|
||||||
|
rec.push_raw_mic(&frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Read config once per frame (try_lock: non-blocking, falls back to
|
// Read config once per frame (try_lock: non-blocking, falls back to
|
||||||
// last-known values if the lock is contended — safe to miss one frame).
|
// last-known values if the lock is contended — safe to miss one frame).
|
||||||
let (run_ns, run_agc, run_hpf, vad_backend, vad_hangover, debug_wav_dump_enabled) = self
|
let (
|
||||||
|
run_ns,
|
||||||
|
run_agc,
|
||||||
|
run_hpf,
|
||||||
|
vad_backend,
|
||||||
|
vad_hangover,
|
||||||
|
debug_wav_dump_enabled,
|
||||||
|
route,
|
||||||
|
processing_backend,
|
||||||
|
) = self
|
||||||
.audio_processing_config
|
.audio_processing_config
|
||||||
.try_lock()
|
.try_lock()
|
||||||
.map(|cfg| {
|
.map(|cfg| {
|
||||||
@@ -319,6 +331,8 @@ impl IosCaptureState {
|
|||||||
cfg.vad_backend,
|
cfg.vad_backend,
|
||||||
cfg.vad_hangover_ms,
|
cfg.vad_hangover_ms,
|
||||||
cfg.debug_wav_dump_enabled,
|
cfg.debug_wav_dump_enabled,
|
||||||
|
cfg.route,
|
||||||
|
cfg.processing_backend,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or((
|
.unwrap_or((
|
||||||
@@ -328,13 +342,10 @@ impl IosCaptureState {
|
|||||||
crate::VadBackend::WebrtcVad,
|
crate::VadBackend::WebrtcVad,
|
||||||
crate::voice_activity::VAD_HANGOVER_MS,
|
crate::voice_activity::VAD_HANGOVER_MS,
|
||||||
false,
|
false,
|
||||||
|
crate::AudioRoute::Unknown,
|
||||||
|
crate::AudioBackend::PlatformVoiceProcessing,
|
||||||
));
|
));
|
||||||
|
|
||||||
// VPIO realtime callbacks cannot use WavDebugRecorder today: its push
|
|
||||||
// path allocates per frame. Debug WAV capture is intentionally disabled
|
|
||||||
// here until the recorder can hand off preallocated frames.
|
|
||||||
let _ = debug_wav_dump_enabled;
|
|
||||||
|
|
||||||
let voice_activity_mode = self
|
let voice_activity_mode = self
|
||||||
.voice_activity_selector
|
.voice_activity_selector
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -347,13 +358,32 @@ impl IosCaptureState {
|
|||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Switch VAD backend only while VoiceActivity mode is active.
|
||||||
|
if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() {
|
||||||
|
if debug_wav_dump_enabled {
|
||||||
|
if recorder_guard.is_none() {
|
||||||
|
*recorder_guard = Some(crate::debug_wav::WavDebugRecorder::start(
|
||||||
|
route,
|
||||||
|
processing_backend,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if let Some(recorder) = recorder_guard.take() {
|
||||||
|
recorder.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if voice_activity_mode && vad_backend != self.current_vad_backend {
|
if voice_activity_mode && vad_backend != self.current_vad_backend {
|
||||||
self.current_vad_backend = vad_backend;
|
self.current_vad_backend = vad_backend;
|
||||||
self.fallback_warned_backend = None;
|
self.fallback_warned_backend = None;
|
||||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
if vad_backend == crate::VadBackend::SileroOnnx {
|
||||||
self.silero_coreml_worker = None;
|
self.silero_coreml_worker =
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
|
||||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
if self.silero_coreml_worker.is_none() {
|
||||||
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||||
|
} else {
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.silero_coreml_worker = None;
|
self.silero_coreml_worker = None;
|
||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
@@ -406,38 +436,20 @@ impl IosCaptureState {
|
|||||||
speech: true,
|
speech: true,
|
||||||
}
|
}
|
||||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||||
match crate::vad::callback_vad_worker_policy(
|
if let Some(worker) = self.silero_coreml_worker.as_ref() {
|
||||||
voice_activity_mode,
|
let enqueued = worker.try_send(capture_seq, &frame);
|
||||||
vad_backend,
|
if !worker.is_stale(capture_seq) {
|
||||||
self.silero_coreml_worker.is_some(),
|
let p = worker.latest_probability();
|
||||||
) {
|
crate::vad::VadOutput {
|
||||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
probability: p,
|
||||||
let worker = self
|
speech: p >= 0.5,
|
||||||
.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::VadWorkerPolicy::UseFallback => {
|
crate::vad::VadOutput {
|
||||||
|
probability: 0.0,
|
||||||
|
speech: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
used_fallback_vad = true;
|
used_fallback_vad = true;
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
@@ -445,10 +457,10 @@ impl IosCaptureState {
|
|||||||
&frame,
|
&frame,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
} else {
|
||||||
probability: 1.0,
|
used_fallback_vad = true;
|
||||||
speech: true,
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
},
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
@@ -471,6 +483,13 @@ impl IosCaptureState {
|
|||||||
transmit_active,
|
transmit_active,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// WAV tap: processed mic (after Rust DSP, DIAG_002).
|
||||||
|
if let Ok(guard) = self.wav_recorder.try_lock() {
|
||||||
|
if let Some(rec) = guard.as_ref() {
|
||||||
|
rec.push_processed_mic(&frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to i16 for accumulation.
|
// Convert to i16 for accumulation.
|
||||||
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
|
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
|
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
|
||||||
@@ -508,13 +527,7 @@ impl IosCaptureState {
|
|||||||
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
|
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
|
||||||
for i in 0..pre_roll_to_emit {
|
for i in 0..pre_roll_to_emit {
|
||||||
let idx = (oldest + i) % PRE_ROLL_FRAMES;
|
let idx = (oldest + i) % PRE_ROLL_FRAMES;
|
||||||
if crate::capture_accumulator::append_i16_bounded(
|
self.pcm_accum.extend_from_slice(&self.pre_roll_buf[idx]);
|
||||||
&mut self.pcm_accum,
|
|
||||||
&self.pre_roll_buf[idx],
|
|
||||||
) {
|
|
||||||
self.audio_processing_stats.increment_callback_xrun();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if !transmit_active {
|
} else if !transmit_active {
|
||||||
// Gate closed — reset the flush flag so pre-roll fires again
|
// Gate closed — reset the flush flag so pre-roll fires again
|
||||||
@@ -526,9 +539,7 @@ impl IosCaptureState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if crate::capture_accumulator::append_i16_bounded(&mut self.pcm_accum, &pcm_frame) {
|
self.pcm_accum.extend_from_slice(&pcm_frame);
|
||||||
self.audio_processing_stats.increment_callback_xrun();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,13 +550,6 @@ pub struct IosVoiceUnit {
|
|||||||
// wrapper's own Drop calls AudioComponentInstanceDispose
|
// wrapper's own Drop calls AudioComponentInstanceDispose
|
||||||
// after stop returns.
|
// after stop returns.
|
||||||
unit: Option<AudioUnit>,
|
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 {
|
impl IosVoiceUnit {
|
||||||
@@ -647,44 +651,6 @@ impl IosVoiceUnit {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AudioError::Backend(format!("vpio enable input I/O: {e}")))?;
|
.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
|
// Note: we keep VPIO's voice processing chain ENABLED
|
||||||
// (AEC + AGC + NS on the mic path) because it gives us
|
// (AEC + AGC + NS on the mic path) because it gives us
|
||||||
// clean capture for free. The historical playback
|
// clean capture for free. The historical playback
|
||||||
@@ -734,7 +700,18 @@ impl IosVoiceUnit {
|
|||||||
// scratch are owned by the closure — no Mutex needed
|
// scratch are owned by the closure — no Mutex needed
|
||||||
// because the input callback is the sole writer/reader on
|
// because the input callback is the sole writer/reader on
|
||||||
// the audio thread.
|
// the audio thread.
|
||||||
let mut capture_state = IosCaptureState::new(¶ms)?;
|
let wav_recorder = Arc::new(Mutex::new({
|
||||||
|
let cfg = params.audio_processing_config.lock().unwrap().clone();
|
||||||
|
if cfg.debug_wav_dump_enabled {
|
||||||
|
Some(crate::debug_wav::WavDebugRecorder::start(
|
||||||
|
cfg.route,
|
||||||
|
cfg.processing_backend,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let mut capture_state = IosCaptureState::new(¶ms, wav_recorder.clone())?;
|
||||||
|
|
||||||
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
// VPIO with our pinned stream format delivers
|
// VPIO with our pinned stream format delivers
|
||||||
@@ -760,14 +737,13 @@ impl IosVoiceUnit {
|
|||||||
// buffer + mix. Same primitive cpal + SDL output
|
// buffer + mix. Same primitive cpal + SDL output
|
||||||
// paths use; this is the platform-neutral playback
|
// paths use; this is the platform-neutral playback
|
||||||
// contract from `tsclientlib::audio::AudioHandler`.
|
// contract from `tsclientlib::audio::AudioHandler`.
|
||||||
// 2. Downmix to mono i16 with master gain, then copy that
|
// 2. Downmix to i16 mono with master gain. VPIO expects
|
||||||
// mono sample across every output channel the callback
|
// mono int16 (the stream format we pinned above);
|
||||||
// exposes. We still request mono Int16 from VPIO, but
|
// the handler produces stereo f32. We average L+R
|
||||||
// the callback must respect the actual channel count it
|
// to a single mono channel rather than dropping R —
|
||||||
// receives. The handler itself produces stereo f32, so
|
// the cpal-side mono-output path made the same
|
||||||
// we average L+R rather than dropping R — the cpal-side
|
// mistake briefly (commit 6a4dbad / fix) and lost
|
||||||
// mono-output path made the same mistake briefly
|
// half the spatial mix.
|
||||||
// (commit 6a4dbad / fix) and lost half the spatial mix.
|
|
||||||
// 3. Local-mute zeroes the output but STILL drains
|
// 3. Local-mute zeroes the output but STILL drains
|
||||||
// AudioHandler in step 1 so its jitter buffer
|
// AudioHandler in step 1 so its jitter buffer
|
||||||
// doesn't grow unbounded while muted. This is the
|
// doesn't grow unbounded while muted. This is the
|
||||||
@@ -796,270 +772,126 @@ impl IosVoiceUnit {
|
|||||||
//
|
//
|
||||||
// Revert to direct call: render callback locks
|
// Revert to direct call: render callback locks
|
||||||
// AudioHandler, asks for `num_frames` stereo frames, and
|
// AudioHandler, asks for `num_frames` stereo frames, and
|
||||||
// immediately downmixes to mono i16 replicated across the
|
// immediately downmixes to i16 mono into the output
|
||||||
// callback's actual output channels. Same as Linux/SDL,
|
// buffer. Same as Linux/SDL, just stereo-f32 -> mono-i16
|
||||||
// just stereo-f32 -> interleaved-i16 converted at the
|
// converted at the boundary.
|
||||||
// boundary.
|
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(2048);
|
||||||
// macOS cadence-fix (commit-f):
|
let handler_for_render = params.handler.clone();
|
||||||
//
|
let output_gain_for_render = params.output_gain.clone();
|
||||||
// The render callback is the wrong place to call AudioHandler::fill_buffer.
|
let output_muted_for_render = params.output_muted.clone();
|
||||||
// macOS VPIO invokes us with `num_frames=512` (= 10.67 ms @ 48 kHz) which
|
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
||||||
// is NOT a multiple of the 20 ms Opus frame size that AudioHandler expects
|
let wav_recorder_for_render = wav_recorder.clone();
|
||||||
// inside fill_buffer. The mismatch (a) leaves fill_buffer unable to
|
// Diagnostic counters (sampled every 100 callbacks ~= 2 s).
|
||||||
// satisfy the request on most calls and (b) advances MAX_PACKET_LOSSES,
|
let mut cb_count: u64 = 0;
|
||||||
// which removes the talker and starts PLC silence/decay.
|
let mut last_num_frames: usize = 0;
|
||||||
//
|
let mut num_frames_changes: u32 = 0;
|
||||||
// Converged fix across Mumble (Speex jitter buffer + decoded PCM FIFO),
|
let mut callbacks_with_audio: u64 = 0;
|
||||||
// WebRTC NetEQ (adaptive 80ms prebuffer + sync buffer), Songbird (Fill→
|
let mut callbacks_with_silence: u64 = 0;
|
||||||
// Drain playout buffer), cpal/rodio (SPSC ring + zero-fill on underrun),
|
let mut render_ref_accum = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
// tsclientlib's own SDL example, and the upstream tsclientlib::audio
|
let mut render_ref_len: usize = 0;
|
||||||
// contract: separate ingress quantum (20 ms decoded PCM = 1920 stereo
|
let mut render_recorder_active = false;
|
||||||
// f32) from egress quantum (whatever VPIO asks for), connected by a
|
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
// lock-free ring of decoded PCM.
|
let out: &mut [i16] = args.data.buffer;
|
||||||
//
|
let num_frames = out.len();
|
||||||
// Layout:
|
// AudioHandler produces 48 kHz stereo f32 (= num_frames * 2 floats).
|
||||||
// * macOS: spawn a tokio producer task paced at 20 ms; each tick
|
let needed = num_frames * 2;
|
||||||
// try-locks AudioHandler, calls fill_buffer(1920), pushes 1920
|
if scratch_stereo.len() < needed {
|
||||||
// f32 into the ring. The render callback only pops — no lock,
|
scratch_stereo.resize(needed, 0.0);
|
||||||
// no Opus decode, no allocator on the audio thread. 60 ms
|
}
|
||||||
// prebuffer (3 × 20 ms) is held before the callback starts
|
// Zero the live slice. AudioHandler::fill_buffer is
|
||||||
// draining, matching Mumble's playout margin and WebRTC's
|
// additive (does NOT clear); residual values from
|
||||||
// kStartDelayMs order of magnitude. Crossbeam ArrayQueue
|
// earlier callbacks (when scratch was bigger) would
|
||||||
// is used because we already depend on crossbeam-queue.
|
// leak through otherwise.
|
||||||
// * iOS: keep the existing direct fill_buffer path — VPIO on iOS
|
scratch_stereo[..needed].fill(0.0);
|
||||||
// requests 480-frame slices that ARE 20 ms aligned so the
|
// Non-blocking fill on the realtime callback thread.
|
||||||
// cadence mismatch does not arise there.
|
// If the inbound forwarder currently owns this mutex,
|
||||||
#[cfg(target_os = "macos")]
|
// emit this period as silence instead of blocking and
|
||||||
let producer_shutdown = Arc::new(AtomicBool::new(false));
|
// risking an AudioUnit underrun pop/click.
|
||||||
|
match handler_for_render.try_lock() {
|
||||||
#[cfg(target_os = "macos")]
|
Ok(mut h) => {
|
||||||
{
|
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
|
||||||
// 100 ms capacity = 9600 stereo f32. Sized so that the 60 ms
|
|
||||||
// prebuffer plus a few jitter spikes fit without forcing the
|
|
||||||
// producer to drop frames. crossbeam ArrayQueue is fixed-cap
|
|
||||||
// and lock-free SPSC-ish (MPMC but wait-free per end); for
|
|
||||||
// single producer + single consumer it's effectively SPSC.
|
|
||||||
const RING_CAPACITY: usize = 12000;
|
|
||||||
const PULL_SAMPLES: usize = 1920; // one 20 ms Opus frame, stereo
|
|
||||||
const PREBUFFER_SAMPLES: usize = 9600; // 100 ms @ 48 kHz stereo
|
|
||||||
|
|
||||||
let pcm_ring: Arc<ArrayQueue<f32>> = Arc::new(ArrayQueue::new(RING_CAPACITY));
|
|
||||||
let pcm_ring_producer = pcm_ring.clone();
|
|
||||||
let pcm_ring_consumer = pcm_ring.clone();
|
|
||||||
let handler_for_producer = params.handler.clone();
|
|
||||||
let output_gain_for_render = params.output_gain.clone();
|
|
||||||
let output_muted_for_render = params.output_muted.clone();
|
|
||||||
|
|
||||||
let producer_shutdown_for_task = producer_shutdown.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut pull_scratch: Vec<f32> = vec![0.0; PULL_SAMPLES];
|
|
||||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
|
||||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
if producer_shutdown_for_task.load(Ordering::Relaxed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match handler_for_producer.try_lock() {
|
|
||||||
Ok(mut h) => {
|
|
||||||
pull_scratch.fill(0.0);
|
|
||||||
let _ = h.fill_buffer(&mut pull_scratch[..]);
|
|
||||||
for &s in &pull_scratch {
|
|
||||||
pcm_ring_producer.force_push(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(std::sync::TryLockError::WouldBlock) => {}
|
|
||||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
|
||||||
warn!(target: "chanora_audio",
|
|
||||||
"producer: AudioHandler mutex poisoned: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
Err(std::sync::TryLockError::WouldBlock) => {
|
||||||
|
|
||||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
|
||||||
let render_callback::Args {
|
|
||||||
data, num_frames, ..
|
|
||||||
} = args;
|
|
||||||
let out: &mut [i16] = data.buffer;
|
|
||||||
let out_channels = data.channels;
|
|
||||||
let needed = num_frames * out_channels;
|
|
||||||
|
|
||||||
if pcm_ring_consumer.len() < PREBUFFER_SAMPLES {
|
|
||||||
for sample in &mut out[..needed] {
|
|
||||||
*sample = 0;
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pop L,R as a pair per frame. The pairing is
|
|
||||||
// load-bearing — `written += 2` half-buffer bug.
|
|
||||||
let mut written_frames: usize = 0;
|
|
||||||
while written_frames < num_frames {
|
|
||||||
let l = match pcm_ring_consumer.pop() {
|
|
||||||
Some(v) => v,
|
|
||||||
None => break,
|
|
||||||
};
|
|
||||||
let r = pcm_ring_consumer.pop().unwrap_or(0.0);
|
|
||||||
let mut frame = [l, r];
|
|
||||||
crate::voice_render::limit_peak_inplace(&mut frame, 0.99);
|
|
||||||
let (l_lim, r_lim) = (frame[0], frame[1]);
|
|
||||||
let base = written_frames * out_channels;
|
|
||||||
if out_channels == 1 {
|
|
||||||
let mono = (l_lim + r_lim) * 0.5;
|
|
||||||
out[base] = (mono.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
|
||||||
} else {
|
|
||||||
out[base] = (l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
|
||||||
out[base + 1] = (r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
|
||||||
}
|
|
||||||
written_frames += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if written_frames < num_frames {
|
|
||||||
let remaining = num_frames - written_frames;
|
|
||||||
for f in 0..remaining {
|
|
||||||
let base = (written_frames + f) * out_channels;
|
|
||||||
for c in 0..out_channels {
|
|
||||||
out[base + c] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
|
||||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
|
||||||
if muted {
|
|
||||||
for sample in &mut out[..needed] {
|
|
||||||
*sample = 0;
|
|
||||||
}
|
|
||||||
} else if gain != 1.0 {
|
|
||||||
for sample in &mut out[..needed] {
|
|
||||||
*sample = (((*sample as f32) * gain)
|
|
||||||
.clamp(i16::MIN as f32, i16::MAX as f32))
|
|
||||||
as i16;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// iOS path: direct fill_buffer in callback. iOS VPIO
|
|
||||||
// requests 480-frame slices that align with tsclientlib's
|
|
||||||
// 20 ms Opus frame, so the cadence mismatch that macOS
|
|
||||||
// hits does not arise here. macOS has its own cfg-gated
|
|
||||||
// producer-task path above.
|
|
||||||
#[cfg(target_os = "ios")]
|
|
||||||
{
|
|
||||||
let mut scratch_stereo: Vec<f32> = vec![0.0; IOS_RENDER_SCRATCH_FRAMES * 2];
|
|
||||||
let handler_for_render = params.handler.clone();
|
|
||||||
let output_gain_for_render = params.output_gain.clone();
|
|
||||||
let output_muted_for_render = params.output_muted.clone();
|
|
||||||
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
|
||||||
// Level meter decimation: the render callback fires ~93
|
|
||||||
// times/sec, but the bridge consumer reads at ~30 Hz.
|
|
||||||
let mut render_level_decimation: u32 = 0;
|
|
||||||
// Debug WAV render-reference capture is intentionally unavailable
|
|
||||||
// on iOS VPIO callbacks until WavDebugRecorder supports a
|
|
||||||
// preallocated handoff; its current push path allocates per frame.
|
|
||||||
// Diagnostic counters sampled every 100 callbacks.
|
|
||||||
let mut cb_count: u64 = 0;
|
|
||||||
let mut last_num_frames: usize = 0;
|
|
||||||
let mut num_frames_changes: u64 = 0;
|
|
||||||
let mut callbacks_with_audio: u64 = 0;
|
|
||||||
let mut callbacks_with_silence: u64 = 0;
|
|
||||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
|
||||||
let render_callback::Args {
|
|
||||||
data, num_frames, ..
|
|
||||||
} = args;
|
|
||||||
let out: &mut [i16] = data.buffer;
|
|
||||||
let out_channels = data.channels;
|
|
||||||
let process_frames = num_frames.min(IOS_RENDER_SCRATCH_FRAMES);
|
|
||||||
if process_frames < num_frames {
|
|
||||||
audio_processing_stats_for_render.increment_callback_xrun();
|
audio_processing_stats_for_render.increment_callback_xrun();
|
||||||
|
// scratch_stereo is already zeroed above.
|
||||||
}
|
}
|
||||||
// AudioHandler produces 48 kHz stereo f32 (= frames * 2 floats).
|
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||||
let needed = process_frames * 2;
|
// Never panic on the realtime IO thread.
|
||||||
// Zero the live slice. AudioHandler::fill_buffer is
|
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
|
||||||
// additive (does NOT clear); residual values from
|
|
||||||
// earlier callbacks (when scratch was bigger) would
|
|
||||||
// leak through otherwise.
|
|
||||||
scratch_stereo[..needed].fill(0.0);
|
|
||||||
match handler_for_render.try_lock() {
|
|
||||||
Ok(mut h) => {
|
|
||||||
let _ = h.fill_buffer(&mut scratch_stereo[..needed]);
|
|
||||||
}
|
|
||||||
Err(std::sync::TryLockError::WouldBlock) => {
|
|
||||||
audio_processing_stats_for_render.increment_callback_xrun();
|
|
||||||
// scratch_stereo is already zeroed above.
|
|
||||||
}
|
|
||||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
|
||||||
// Never panic on the realtime IO thread.
|
|
||||||
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Peak limiter — multi-client mixes can sum past 0 dBFS;
|
|
||||||
// without this the downmix helper would hard-clip to i16::MAX.
|
|
||||||
crate::voice_render::limit_peak_inplace(&mut scratch_stereo[..needed], 0.99);
|
|
||||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
|
||||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
|
||||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
|
||||||
&scratch_stereo[..needed],
|
|
||||||
out,
|
|
||||||
out_channels,
|
|
||||||
gain,
|
|
||||||
muted,
|
|
||||||
);
|
|
||||||
if mix_stats.clipped_samples > 0 {
|
|
||||||
audio_processing_stats_for_render
|
|
||||||
.add_clipped_samples(mix_stats.clipped_samples);
|
|
||||||
}
|
|
||||||
render_level_decimation = render_level_decimation.wrapping_add(1);
|
|
||||||
if render_level_decimation % 3 == 0 {
|
|
||||||
audio_processing_stats_for_render.update_render(
|
|
||||||
crate::frame::dbfs(&scratch_stereo[..needed]),
|
|
||||||
num_frames as u32,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Track audio-vs-silence for the diagnostic.
|
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||||
if mix_stats.peak_i16 > 0 {
|
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||||
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
||||||
|
&scratch_stereo[..needed],
|
||||||
|
out,
|
||||||
|
gain,
|
||||||
|
muted,
|
||||||
|
);
|
||||||
|
if mix_stats.clipped_samples > 0 {
|
||||||
|
audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples);
|
||||||
|
}
|
||||||
|
audio_processing_stats_for_render.update_render(
|
||||||
|
crate::frame::dbfs(&scratch_stereo[..needed]),
|
||||||
|
num_frames as u32,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Ok(guard) = wav_recorder_for_render.try_lock() {
|
||||||
|
if let Some(rec) = guard.as_ref() {
|
||||||
|
if !render_recorder_active {
|
||||||
|
render_ref_len = 0;
|
||||||
|
render_ref_accum.fill(0.0);
|
||||||
|
render_recorder_active = true;
|
||||||
|
}
|
||||||
|
let mut idx = 0;
|
||||||
|
while idx + 1 < needed {
|
||||||
|
let mono = (scratch_stereo[idx] + scratch_stereo[idx + 1]) * 0.5;
|
||||||
|
render_ref_accum[render_ref_len] = mono;
|
||||||
|
render_ref_len += 1;
|
||||||
|
idx += 2;
|
||||||
|
if render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||||
|
rec.push_render_reference(&render_ref_accum);
|
||||||
|
render_ref_len = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
render_recorder_active = false;
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
render_recorder_active = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Diagnostic sampling.
|
// Track audio-vs-silence for the diagnostic.
|
||||||
if last_num_frames != 0 && last_num_frames != num_frames {
|
if mix_stats.peak_i16 > 0 {
|
||||||
num_frames_changes = num_frames_changes.wrapping_add(1);
|
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
||||||
}
|
} else {
|
||||||
last_num_frames = num_frames;
|
audio_processing_stats_for_render.increment_output_underrun();
|
||||||
cb_count = cb_count.wrapping_add(1);
|
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
||||||
if cb_count.is_multiple_of(100) {
|
}
|
||||||
debug!(
|
|
||||||
target: "chanora_audio",
|
// Diagnostic sampling.
|
||||||
cb = cb_count,
|
if last_num_frames != 0 && last_num_frames != num_frames {
|
||||||
num_frames,
|
num_frames_changes = num_frames_changes.wrapping_add(1);
|
||||||
frames_changes = num_frames_changes,
|
}
|
||||||
callbacks_with_audio,
|
last_num_frames = num_frames;
|
||||||
callbacks_with_silence,
|
cb_count = cb_count.wrapping_add(1);
|
||||||
peak_out_i16 = mix_stats.peak_i16,
|
if cb_count.is_multiple_of(100) {
|
||||||
gain,
|
debug!(
|
||||||
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
target: "chanora_audio",
|
||||||
);
|
cb = cb_count,
|
||||||
}
|
num_frames,
|
||||||
Ok(())
|
frames_changes = num_frames_changes,
|
||||||
})
|
callbacks_with_audio,
|
||||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
callbacks_with_silence,
|
||||||
} // end #[cfg(target_os = "ios")] block
|
peak_out_i16 = mix_stats.peak_i16,
|
||||||
|
gain,
|
||||||
|
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||||
|
|
||||||
// Finalise the unit — allocates internal buffers per the
|
// Finalise the unit — allocates internal buffers per the
|
||||||
// stream formats we set above. After initialize() most
|
// stream formats we set above. After initialize() most
|
||||||
@@ -1162,11 +994,7 @@ impl IosVoiceUnit {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self { unit: Some(unit) })
|
||||||
unit: Some(unit),
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
producer_shutdown,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restart the audio unit after route change handling.
|
/// Restart the audio unit after route change handling.
|
||||||
@@ -1210,11 +1038,6 @@ impl IosVoiceUnit {
|
|||||||
|
|
||||||
impl Drop for IosVoiceUnit {
|
impl Drop for IosVoiceUnit {
|
||||||
fn drop(&mut self) {
|
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
|
// Stop the audio unit so the render callback no longer
|
||||||
// fires. The coreaudio-rs wrapper's own Drop calls
|
// fires. The coreaudio-rs wrapper's own Drop calls
|
||||||
// AudioComponentInstanceDispose afterwards.
|
// AudioComponentInstanceDispose afterwards.
|
||||||
|
|||||||
@@ -28,17 +28,9 @@
|
|||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
#[cfg(any(target_os = "android", test))]
|
#[cfg(target_os = "android")]
|
||||||
#[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;
|
mod audio_event_queue;
|
||||||
pub mod audio_processing;
|
pub mod audio_processing;
|
||||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
|
||||||
mod capture_accumulator;
|
|
||||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
|
||||||
mod capture_resampler;
|
|
||||||
pub mod debug_wav;
|
pub mod debug_wav;
|
||||||
mod engine;
|
mod engine;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
@@ -49,11 +41,6 @@ pub mod processor;
|
|||||||
pub mod ptt;
|
pub mod ptt;
|
||||||
pub mod ptt_backends;
|
pub mod ptt_backends;
|
||||||
pub mod release_tail;
|
pub mod release_tail;
|
||||||
#[cfg_attr(
|
|
||||||
not(any(target_os = "android", target_os = "ios", test)),
|
|
||||||
allow(dead_code)
|
|
||||||
)]
|
|
||||||
pub(crate) mod render_reference;
|
|
||||||
pub mod route_policy;
|
pub mod route_policy;
|
||||||
pub mod transmit_mode;
|
pub mod transmit_mode;
|
||||||
pub mod transmit_selector;
|
pub mod transmit_selector;
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ use std::sync::atomic::{AtomicBool, AtomicU32};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
use tsclientlib::audio::AudioHandler;
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
|
||||||
use crate::engine::SessionAudioId;
|
use crate::engine::SessionAudioId;
|
||||||
@@ -67,7 +70,7 @@ pub type BackendEventTx = mpsc::UnboundedSender<BackendEvent>;
|
|||||||
pub type AudioSessionId = i32;
|
pub type AudioSessionId = i32;
|
||||||
|
|
||||||
/// Engine-owned state shared with mobile voice audio callbacks.
|
/// Engine-owned state shared with mobile voice audio callbacks.
|
||||||
#[cfg_attr(target_os = "ios", derive(Clone))]
|
#[cfg_attr(not(target_os = "android"), derive(Clone))]
|
||||||
pub(crate) struct VoiceAudioParams {
|
pub(crate) struct VoiceAudioParams {
|
||||||
/// Opus-encoded voice packets sent on this channel toward the
|
/// Opus-encoded voice packets sent on this channel toward the
|
||||||
/// protocol layer.
|
/// protocol layer.
|
||||||
@@ -85,10 +88,7 @@ pub(crate) struct VoiceAudioParams {
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub event_producer: crate::audio_event_queue::AudioEventProducer,
|
pub event_producer: crate::audio_event_queue::AudioEventProducer,
|
||||||
/// AudioHandler that inbound decode+mix feeds into; the output
|
/// AudioHandler that inbound decode+mix feeds into; the output
|
||||||
/// callback pulls mixed stereo f32 from it. iOS, macOS, and desktop
|
/// callback pulls mixed stereo f32 from it.
|
||||||
/// 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"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||||
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
|
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
|
||||||
|
|||||||
@@ -3,18 +3,15 @@ use audiopus::{
|
|||||||
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
||||||
SampleRate as OpusSampleRate,
|
SampleRate as OpusSampleRate,
|
||||||
};
|
};
|
||||||
use crossbeam::queue::ArrayQueue;
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
||||||
|
|
||||||
use crate::AudioError;
|
use crate::AudioError;
|
||||||
|
|
||||||
pub(crate) const MAX_OPUS_FRAME: usize = 1275;
|
pub(crate) const MAX_OPUS_FRAME: usize = 1275;
|
||||||
const VOICE_FRAME_QUEUE_CAPACITY: usize = 64;
|
|
||||||
|
|
||||||
const VOIP_BITRATE_BPS: i32 = 32_000;
|
const VOIP_BITRATE_BPS: i32 = 32_000;
|
||||||
const VOIP_COMPLEXITY: u8 = 10;
|
const VOIP_COMPLEXITY: u8 = 10;
|
||||||
@@ -57,129 +54,10 @@ pub(crate) fn tune_voip_encoder(encoder: &mut OpusEncoder, context: &str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct EncodedVoiceFrame {
|
|
||||||
data: [u8; MAX_OPUS_FRAME],
|
|
||||||
len: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct EncodedVoiceFrameSender {
|
|
||||||
queue: Arc<ArrayQueue<EncodedVoiceFrame>>,
|
|
||||||
open: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum EncodedVoiceFrameSendError {
|
|
||||||
Full,
|
|
||||||
Closed,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EncodedVoiceFrameSender {
|
|
||||||
fn new(capacity: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
queue: Arc::new(ArrayQueue::new(capacity)),
|
|
||||||
open: Arc::new(AtomicBool::new(true)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn worker_queue(&self) -> Arc<ArrayQueue<EncodedVoiceFrame>> {
|
|
||||||
Arc::clone(&self.queue)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn worker_open_flag(&self) -> Arc<AtomicBool> {
|
|
||||||
Arc::clone(&self.open)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push(&self, frame: EncodedVoiceFrame) -> Result<(), EncodedVoiceFrameSendError> {
|
|
||||||
if !self.open.load(Ordering::Relaxed) {
|
|
||||||
return Err(EncodedVoiceFrameSendError::Closed);
|
|
||||||
}
|
|
||||||
self.queue
|
|
||||||
.push(frame)
|
|
||||||
.map_err(|_| EncodedVoiceFrameSendError::Full)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn start_out_packet_worker(
|
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
|
||||||
frames_sent: Arc<AtomicU32>,
|
|
||||||
context: &'static str,
|
|
||||||
) -> Result<EncodedVoiceFrameSender, AudioError> {
|
|
||||||
start_out_packet_worker_with_spawner(voice_out_tx, frames_sent, context, |name, worker| {
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name(name)
|
|
||||||
.spawn(worker)
|
|
||||||
.map(|_| ())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn start_out_packet_worker_with_spawner<S>(
|
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
|
||||||
frames_sent: Arc<AtomicU32>,
|
|
||||||
context: &'static str,
|
|
||||||
spawn: S,
|
|
||||||
) -> Result<EncodedVoiceFrameSender, AudioError>
|
|
||||||
where
|
|
||||||
S: FnOnce(String, Box<dyn FnOnce() + Send + 'static>) -> std::io::Result<()>,
|
|
||||||
{
|
|
||||||
let tx = EncodedVoiceFrameSender::new(VOICE_FRAME_QUEUE_CAPACITY);
|
|
||||||
let rx = tx.worker_queue();
|
|
||||||
let worker_open = tx.worker_open_flag();
|
|
||||||
spawn(
|
|
||||||
format!("chanora-{context}-voice-packets"),
|
|
||||||
Box::new(move || {
|
|
||||||
loop {
|
|
||||||
let Some(frame) = rx.pop() else {
|
|
||||||
if Arc::strong_count(&rx) == 1 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let packet = OutAudio::new(&AudioData::C2S {
|
|
||||||
id: 0,
|
|
||||||
codec: CodecType::OpusVoice,
|
|
||||||
data: frame.as_slice(),
|
|
||||||
});
|
|
||||||
match voice_out_tx.try_send(packet) {
|
|
||||||
Ok(()) => {
|
|
||||||
frames_sent.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
|
||||||
warn!(target: "chanora_audio", context = %context, "voice_out queue full; dropping frame");
|
|
||||||
}
|
|
||||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
|
||||||
debug!(target: "chanora_audio", context = %context, "voice_out closed; voice packet worker stopping");
|
|
||||||
worker_open.store(false, Ordering::Relaxed);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.map_err(|e| {
|
|
||||||
tx.open.store(false, Ordering::Relaxed);
|
|
||||||
AudioError::Backend(format!("voice packet worker spawn ({context}): {e}"))
|
|
||||||
})?;
|
|
||||||
Ok(tx)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EncodedVoiceFrame {
|
|
||||||
fn try_from_opus(opus_out: &[u8], len: usize) -> Option<Self> {
|
|
||||||
if len > opus_out.len() || len > MAX_OPUS_FRAME {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut data = [0u8; MAX_OPUS_FRAME];
|
|
||||||
data[..len].copy_from_slice(&opus_out[..len]);
|
|
||||||
Some(Self { data, len })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_slice(&self) -> &[u8] {
|
|
||||||
&self.data[..self.len]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode-scope send helper for a freshly encoded Opus voice frame.
|
/// Encode-scope send helper for a freshly encoded Opus voice frame.
|
||||||
pub(crate) fn send_voip_frame<F, G>(
|
pub(crate) fn send_voip_frame<F, G>(
|
||||||
voice_out_tx: &EncodedVoiceFrameSender,
|
voice_out_tx: &mpsc::Sender<OutPacket>,
|
||||||
|
frames_sent: &AtomicU32,
|
||||||
opus_out: &[u8],
|
opus_out: &[u8],
|
||||||
len: usize,
|
len: usize,
|
||||||
on_full: F,
|
on_full: F,
|
||||||
@@ -188,77 +66,16 @@ pub(crate) fn send_voip_frame<F, G>(
|
|||||||
F: FnOnce(),
|
F: FnOnce(),
|
||||||
G: FnOnce(),
|
G: FnOnce(),
|
||||||
{
|
{
|
||||||
let Some(frame) = EncodedVoiceFrame::try_from_opus(opus_out, len) else {
|
let packet = OutAudio::new(&AudioData::C2S {
|
||||||
on_full();
|
id: 0,
|
||||||
return;
|
codec: CodecType::OpusVoice,
|
||||||
};
|
data: &opus_out[..len],
|
||||||
match voice_out_tx.push(frame) {
|
});
|
||||||
Ok(()) => {}
|
match voice_out_tx.try_send(packet) {
|
||||||
Err(EncodedVoiceFrameSendError::Full) => on_full(),
|
Ok(()) => {
|
||||||
Err(EncodedVoiceFrameSendError::Closed) => on_closed(),
|
frames_sent.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
Err(mpsc::error::TrySendError::Full(_)) => on_full(),
|
||||||
|
Err(mpsc::error::TrySendError::Closed(_)) => on_closed(),
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encoded_voice_frame_copies_into_fixed_storage() {
|
|
||||||
let source = [7u8; MAX_OPUS_FRAME];
|
|
||||||
|
|
||||||
let frame = EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(frame.as_slice().len(), MAX_OPUS_FRAME);
|
|
||||||
assert!(frame.as_slice().iter().all(|byte| *byte == 7));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encoded_voice_frame_rejects_lengths_beyond_fixed_storage() {
|
|
||||||
let source = [0u8; MAX_OPUS_FRAME];
|
|
||||||
|
|
||||||
assert!(EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME + 1).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encoded_voice_frame_sender_reports_full_without_blocking() {
|
|
||||||
let sender = EncodedVoiceFrameSender::new(1);
|
|
||||||
let source = [3u8; MAX_OPUS_FRAME];
|
|
||||||
let first = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
|
||||||
let second = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
|
||||||
|
|
||||||
assert!(sender.push(first).is_ok());
|
|
||||||
assert!(sender.push(second).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encoded_voice_frame_sender_reports_closed_without_queueing() {
|
|
||||||
let sender = EncodedVoiceFrameSender::new(1);
|
|
||||||
sender.open.store(false, Ordering::Relaxed);
|
|
||||||
let source = [3u8; MAX_OPUS_FRAME];
|
|
||||||
let frame = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
sender.push(frame),
|
|
||||||
Err(EncodedVoiceFrameSendError::Closed)
|
|
||||||
));
|
|
||||||
assert_eq!(sender.queue.len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encoded_voice_frame_sender_reports_spawn_failure() {
|
|
||||||
let (voice_out_tx, _voice_out_rx) = mpsc::channel(1);
|
|
||||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
|
||||||
|
|
||||||
let result = start_out_packet_worker_with_spawner(
|
|
||||||
voice_out_tx,
|
|
||||||
frames_sent,
|
|
||||||
"test",
|
|
||||||
|_name, _worker| Err(std::io::Error::other("spawn failed")),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
matches!(result, Err(AudioError::Backend(message)) if message.contains("spawn failed"))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
use core::fmt;
|
use core::fmt;
|
||||||
|
|
||||||
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
|
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
mod focused;
|
mod focused;
|
||||||
|
|
||||||
@@ -109,51 +108,35 @@ impl fmt::Display for PttInputClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Errors raised by a desktop PTT backend.
|
/// Errors raised by a desktop PTT backend.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug)]
|
||||||
pub enum PttBackendError {
|
pub enum PttBackendError {
|
||||||
/// The OS rejected the backend initialisation (e.g. Raw Input
|
/// The OS rejected the backend initialisation (e.g. Raw Input
|
||||||
/// registration failed, event tap creation failed).
|
/// registration failed, event tap creation failed).
|
||||||
#[error("init failed: {0}")]
|
|
||||||
Init(String),
|
Init(String),
|
||||||
/// The user-granted permission required for global capture is
|
/// The user-granted permission required for global capture is
|
||||||
/// not granted (typically macOS Input Monitoring / Accessibility).
|
/// not granted (typically macOS Input Monitoring / Accessibility).
|
||||||
#[error("permission denied")]
|
|
||||||
PermissionDenied,
|
PermissionDenied,
|
||||||
/// The display server or compositor does not expose the
|
/// The display server or compositor does not expose the
|
||||||
/// expected interface (typically a non-tested Linux compositor).
|
/// expected interface (typically a non-tested Linux compositor).
|
||||||
#[error("unsupported environment")]
|
|
||||||
UnsupportedEnvironment,
|
UnsupportedEnvironment,
|
||||||
/// Caller submitted a binding whose `platform_key` cannot be
|
/// Caller submitted a binding whose `platform_key` cannot be
|
||||||
/// parsed in the active OS.
|
/// parsed in the active OS.
|
||||||
#[error("invalid binding: {0}")]
|
|
||||||
InvalidBinding(String),
|
InvalidBinding(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
impl fmt::Display for PttBackendError {
|
||||||
mod tests {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
use super::*;
|
match self {
|
||||||
|
Self::Init(s) => write!(f, "init failed: {s}"),
|
||||||
#[test]
|
Self::PermissionDenied => f.write_str("permission denied"),
|
||||||
fn ptt_backend_error_display_strings_stay_stable() {
|
Self::UnsupportedEnvironment => f.write_str("unsupported environment"),
|
||||||
assert_eq!(
|
Self::InvalidBinding(s) => write!(f, "invalid binding: {s}"),
|
||||||
PttBackendError::Init("rawinput".into()).to_string(),
|
}
|
||||||
"init failed: rawinput"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
PttBackendError::PermissionDenied.to_string(),
|
|
||||||
"permission denied"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
PttBackendError::UnsupportedEnvironment.to_string(),
|
|
||||||
"unsupported environment"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
PttBackendError::InvalidBinding("bad key".into()).to_string(),
|
|
||||||
"invalid binding: bad key"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PttBackendError {}
|
||||||
|
|
||||||
/// Cross-platform desktop PTT backend (SDD-081).
|
/// Cross-platform desktop PTT backend (SDD-081).
|
||||||
///
|
///
|
||||||
/// All implementations call exactly the audio transmit gate's
|
/// All implementations call exactly the audio transmit gate's
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
use std::array;
|
|
||||||
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
const NO_LATEST_SLOT: usize = usize::MAX;
|
|
||||||
|
|
||||||
struct Slot<const SAMPLES: usize> {
|
|
||||||
version: AtomicUsize,
|
|
||||||
samples: [AtomicU32; SAMPLES],
|
|
||||||
#[cfg(test)]
|
|
||||||
bump_after_first_sample_read: std::sync::atomic::AtomicBool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<const SAMPLES: usize> Slot<SAMPLES> {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
version: AtomicUsize::new(0),
|
|
||||||
samples: array::from_fn(|_| AtomicU32::new(0.0_f32.to_bits())),
|
|
||||||
#[cfg(test)]
|
|
||||||
bump_after_first_sample_read: std::sync::atomic::AtomicBool::new(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct RenderReferenceFrameAccumulator<const SAMPLES: usize> {
|
|
||||||
pending: [f32; SAMPLES],
|
|
||||||
pending_len: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<const SAMPLES: usize> RenderReferenceFrameAccumulator<SAMPLES> {
|
|
||||||
pub(crate) fn new() -> Self {
|
|
||||||
assert!(
|
|
||||||
SAMPLES > 0,
|
|
||||||
"RenderReferenceFrameAccumulator requires at least one sample"
|
|
||||||
);
|
|
||||||
Self {
|
|
||||||
pending: [0.0; SAMPLES],
|
|
||||||
pending_len: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn push_mono_samples(
|
|
||||||
&mut self,
|
|
||||||
mut samples: &[f32],
|
|
||||||
mut publish: impl FnMut(&[f32; SAMPLES]),
|
|
||||||
) {
|
|
||||||
while !samples.is_empty() {
|
|
||||||
let needed = SAMPLES - self.pending_len;
|
|
||||||
let take = needed.min(samples.len());
|
|
||||||
self.pending[self.pending_len..self.pending_len + take]
|
|
||||||
.copy_from_slice(&samples[..take]);
|
|
||||||
self.pending_len += take;
|
|
||||||
samples = &samples[take..];
|
|
||||||
|
|
||||||
if self.pending_len == SAMPLES {
|
|
||||||
publish(&self.pending);
|
|
||||||
self.pending_len = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn pending_len(&self) -> usize {
|
|
||||||
self.pending_len
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct RenderReferenceBuffer<const SAMPLES: usize, const SLOTS: usize> {
|
|
||||||
slots: Box<[Slot<SAMPLES>; SLOTS]>,
|
|
||||||
write_idx: AtomicUsize,
|
|
||||||
latest_slot: AtomicUsize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<const SAMPLES: usize, const SLOTS: usize> RenderReferenceBuffer<SAMPLES, SLOTS> {
|
|
||||||
pub(crate) fn new() -> Arc<Self> {
|
|
||||||
assert!(
|
|
||||||
SLOTS > 0,
|
|
||||||
"RenderReferenceBuffer requires at least one slot"
|
|
||||||
);
|
|
||||||
Arc::new(Self {
|
|
||||||
slots: Box::new(array::from_fn(|_| Slot::new())),
|
|
||||||
write_idx: AtomicUsize::new(0),
|
|
||||||
latest_slot: AtomicUsize::new(NO_LATEST_SLOT),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn write(&self, frame: &[f32; SAMPLES]) {
|
|
||||||
let idx = self.write_idx.load(Ordering::Relaxed) % SLOTS;
|
|
||||||
let slot = &self.slots[idx];
|
|
||||||
// The acquire half keeps payload stores after the odd in-progress marker.
|
|
||||||
let version = slot.version.fetch_add(1, Ordering::AcqRel);
|
|
||||||
debug_assert_eq!(version & 1, 0, "single writer should only enter even slots");
|
|
||||||
for (sample, value) in slot.samples.iter().zip(frame.iter().copied()) {
|
|
||||||
sample.store(value.to_bits(), Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
slot.version
|
|
||||||
.store(version.wrapping_add(2) & !1, Ordering::Release);
|
|
||||||
self.latest_slot.store(idx, Ordering::Release);
|
|
||||||
self.write_idx.store((idx + 1) % SLOTS, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn read_latest(&self) -> [f32; SAMPLES] {
|
|
||||||
let mut out = [0.0_f32; SAMPLES];
|
|
||||||
self.read_latest_into(&mut out);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn read_latest_into(&self, out: &mut [f32; SAMPLES]) {
|
|
||||||
let idx = self.latest_slot.load(Ordering::Acquire);
|
|
||||||
if idx == NO_LATEST_SLOT {
|
|
||||||
out.fill(0.0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let slot = &self.slots[idx];
|
|
||||||
let before = slot.version.load(Ordering::Acquire);
|
|
||||||
if before & 1 == 1 {
|
|
||||||
out.fill(0.0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(test))]
|
|
||||||
for (dst, sample) in out.iter_mut().zip(slot.samples.iter()) {
|
|
||||||
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
for (idx, (dst, sample)) in out.iter_mut().zip(slot.samples.iter()).enumerate() {
|
|
||||||
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
|
|
||||||
if idx == 0
|
|
||||||
&& slot
|
|
||||||
.bump_after_first_sample_read
|
|
||||||
.swap(false, Ordering::Relaxed)
|
|
||||||
{
|
|
||||||
slot.version.fetch_add(2, Ordering::Release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let after = slot.version.load(Ordering::Acquire);
|
|
||||||
if before != after || after & 1 == 1 {
|
|
||||||
out.fill(0.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn mark_latest_slot_in_progress_for_test(&self) {
|
|
||||||
let idx = self.latest_slot.load(Ordering::Acquire);
|
|
||||||
assert_ne!(idx, NO_LATEST_SLOT);
|
|
||||||
self.slots[idx].version.fetch_or(1, Ordering::Release);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn bump_latest_slot_version_after_first_sample_for_test(&self) {
|
|
||||||
let idx = self.latest_slot.load(Ordering::Acquire);
|
|
||||||
assert_ne!(idx, NO_LATEST_SLOT);
|
|
||||||
self.slots[idx]
|
|
||||||
.bump_after_first_sample_read
|
|
||||||
.store(true, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{RenderReferenceBuffer, RenderReferenceFrameAccumulator};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_reference_reads_zero_before_first_publish() {
|
|
||||||
let buffer = RenderReferenceBuffer::<4, 2>::new();
|
|
||||||
|
|
||||||
assert_eq!(buffer.read_latest(), [0.0; 4]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_reference_reader_gets_latest_complete_frame() {
|
|
||||||
let buffer = RenderReferenceBuffer::<4, 3>::new();
|
|
||||||
|
|
||||||
buffer.write(&[1.0, 2.0, 3.0, 4.0]);
|
|
||||||
buffer.write(&[5.0, 6.0, 7.0, 8.0]);
|
|
||||||
|
|
||||||
assert_eq!(buffer.read_latest(), [5.0, 6.0, 7.0, 8.0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_reference_writes_wrap_without_returning_stale_frame() {
|
|
||||||
let buffer = RenderReferenceBuffer::<2, 2>::new();
|
|
||||||
|
|
||||||
buffer.write(&[1.0, 2.0]);
|
|
||||||
buffer.write(&[3.0, 4.0]);
|
|
||||||
buffer.write(&[5.0, 6.0]);
|
|
||||||
|
|
||||||
assert_eq!(buffer.read_latest(), [5.0, 6.0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_reference_accumulator_publishes_only_complete_frames() {
|
|
||||||
let mut accum = RenderReferenceFrameAccumulator::<4>::new();
|
|
||||||
let mut frames = Vec::new();
|
|
||||||
|
|
||||||
accum.push_mono_samples(&[1.0, 2.0], |frame| frames.push(*frame));
|
|
||||||
|
|
||||||
assert!(frames.is_empty());
|
|
||||||
assert_eq!(accum.pending_len(), 2);
|
|
||||||
|
|
||||||
accum.push_mono_samples(&[3.0, 4.0, 5.0, 6.0, 7.0], |frame| frames.push(*frame));
|
|
||||||
|
|
||||||
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0]]);
|
|
||||||
assert_eq!(accum.pending_len(), 3);
|
|
||||||
|
|
||||||
accum.push_mono_samples(&[8.0], |frame| frames.push(*frame));
|
|
||||||
|
|
||||||
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]]);
|
|
||||||
assert_eq!(accum.pending_len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_reference_reader_rejects_in_progress_slot() {
|
|
||||||
let buffer = RenderReferenceBuffer::<2, 1>::new();
|
|
||||||
|
|
||||||
buffer.write(&[1.0, 2.0]);
|
|
||||||
buffer.mark_latest_slot_in_progress_for_test();
|
|
||||||
|
|
||||||
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_reference_reader_rejects_stale_slot_changed_during_read() {
|
|
||||||
let buffer = RenderReferenceBuffer::<2, 1>::new();
|
|
||||||
|
|
||||||
buffer.write(&[1.0, 2.0]);
|
|
||||||
|
|
||||||
buffer.bump_latest_slot_version_after_first_sample_for_test();
|
|
||||||
|
|
||||||
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[should_panic(expected = "RenderReferenceBuffer requires at least one slot")]
|
|
||||||
fn render_reference_rejects_zero_slots() {
|
|
||||||
let _ = RenderReferenceBuffer::<2, 0>::new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
use std::sync::{OnceLock, RwLock};
|
use std::sync::{OnceLock, RwLock};
|
||||||
|
|
||||||
use crate::frame::{f32_to_i16, i16_to_f32};
|
use crate::frame::{f32_to_i16, i16_to_f32};
|
||||||
use crate::{AudioError, VadBackend};
|
use crate::AudioError;
|
||||||
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
||||||
|
|
||||||
#[cfg(not(target_os = "ios"))]
|
#[cfg(not(target_os = "ios"))]
|
||||||
@@ -108,36 +108,6 @@ pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16
|
|||||||
detector.process_10ms(&frame)
|
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_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
|
||||||
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
@@ -282,32 +252,4 @@ mod tests {
|
|||||||
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
|
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn callback_policy_uses_existing_model_worker_only() {
|
|
||||||
assert_eq!(
|
|
||||||
callback_vad_worker_policy(true, VadBackend::SileroOnnx, true),
|
|
||||||
VadWorkerPolicy::UseWorker
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
callback_vad_worker_policy(true, VadBackend::SileroOnnx, false),
|
|
||||||
VadWorkerPolicy::UseFallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn callback_policy_keeps_disabled_and_webrtc_paths_worker_free() {
|
|
||||||
assert_eq!(
|
|
||||||
callback_vad_worker_policy(false, VadBackend::SileroOnnx, false),
|
|
||||||
VadWorkerPolicy::NotModelBacked
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
callback_vad_worker_policy(true, VadBackend::Disabled, false),
|
|
||||||
VadWorkerPolicy::NotModelBacked
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
callback_vad_worker_policy(true, VadBackend::WebrtcVad, false),
|
|
||||||
VadWorkerPolicy::NotModelBacked
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
/// Diagnostics returned by render downmix helpers.
|
/// Diagnostics returned by render downmix helpers.
|
||||||
#[cfg(any(target_os = "ios", test))]
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub(crate) struct RenderDownmixStats {
|
pub(crate) struct RenderDownmixStats {
|
||||||
/// Peak absolute sample magnitude after i16 conversion.
|
/// Peak absolute sample magnitude after i16 conversion.
|
||||||
@@ -8,45 +7,37 @@ pub(crate) struct RenderDownmixStats {
|
|||||||
pub clipped_samples: u64,
|
pub clipped_samples: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "ios", test))]
|
/// Downmix interleaved stereo f32 samples into mono i16 samples.
|
||||||
pub(crate) fn downmix_stereo_f32_to_interleaved_i16(
|
///
|
||||||
|
/// The helper is allocation-free and safe for realtime render callbacks.
|
||||||
|
/// If the stereo source is shorter than expected, the remainder of `out`
|
||||||
|
/// is filled with silence.
|
||||||
|
pub(crate) fn downmix_stereo_f32_to_mono_i16(
|
||||||
stereo: &[f32],
|
stereo: &[f32],
|
||||||
out: &mut [i16],
|
out: &mut [i16],
|
||||||
out_channels: usize,
|
|
||||||
gain: f32,
|
gain: f32,
|
||||||
muted: bool,
|
muted: bool,
|
||||||
) -> RenderDownmixStats {
|
) -> RenderDownmixStats {
|
||||||
if out_channels == 0 {
|
|
||||||
out.fill(0);
|
|
||||||
return RenderDownmixStats::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
if muted {
|
if muted {
|
||||||
out.fill(0);
|
out.fill(0);
|
||||||
return RenderDownmixStats::default();
|
return RenderDownmixStats::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
let available_frames = stereo.len() / 2;
|
let available_frames = stereo.len() / 2;
|
||||||
let requested_frames = out.len() / out_channels;
|
if available_frames < out.len() {
|
||||||
if available_frames < requested_frames {
|
|
||||||
out.fill(0);
|
out.fill(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut peak = 0_u16;
|
let mut peak = 0_u16;
|
||||||
let mut clipped_samples = 0_u64;
|
let mut clipped_samples = 0_u64;
|
||||||
for (dst_frame, lr) in out
|
for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) {
|
||||||
.chunks_exact_mut(out_channels)
|
|
||||||
.zip(stereo.chunks_exact(2))
|
|
||||||
{
|
|
||||||
let mono = (lr[0] + lr[1]) * 0.5 * gain;
|
let mono = (lr[0] + lr[1]) * 0.5 * gain;
|
||||||
let clamped = mono.clamp(-1.0, 1.0);
|
let clamped = mono.clamp(-1.0, 1.0);
|
||||||
if (mono - clamped).abs() > f32::EPSILON {
|
if (mono - clamped).abs() > f32::EPSILON {
|
||||||
clipped_samples = clipped_samples.saturating_add(1);
|
clipped_samples = clipped_samples.saturating_add(1);
|
||||||
}
|
}
|
||||||
let sample = (clamped * i16::MAX as f32) as i16;
|
let sample = (clamped * i16::MAX as f32) as i16;
|
||||||
for dst in dst_frame.iter_mut() {
|
*dst = sample;
|
||||||
*dst = sample;
|
|
||||||
}
|
|
||||||
peak = peak.max(sample.unsigned_abs());
|
peak = peak.max(sample.unsigned_abs());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,29 +62,6 @@ pub(crate) fn downmix_stereo_f32_to_mono_f32(stereo: &[f32], out: &mut [f32]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-place per-frame peak limiter. Scales the entire buffer so the
|
|
||||||
/// absolute peak equals `threshold`; returns the applied gain (1.0 =
|
|
||||||
/// no reduction). Used on the render path between `AudioHandler` and
|
|
||||||
/// the i16 downmix to prevent hard clipping when a multi-client mix
|
|
||||||
/// exceeds 0 dBFS. Per-frame scaling is sub-millisecond at 48 kHz, so
|
|
||||||
/// the pumping risk is negligible for speech; a look-ahead design was
|
|
||||||
/// rejected because it would add latency on top of the existing
|
|
||||||
/// jitter buffer.
|
|
||||||
pub(crate) fn limit_peak_inplace(samples: &mut [f32], threshold: f32) -> f32 {
|
|
||||||
if threshold <= 0.0 || !threshold.is_finite() {
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
|
|
||||||
if peak <= threshold {
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
let gain = threshold / peak;
|
|
||||||
for s in samples.iter_mut() {
|
|
||||||
*s *= gain;
|
|
||||||
}
|
|
||||||
gain
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -103,7 +71,7 @@ mod tests {
|
|||||||
let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0];
|
let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0];
|
||||||
let mut out = [0_i16; 3];
|
let mut out = [0_i16; 3];
|
||||||
|
|
||||||
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 2.0, false);
|
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 2.0, false);
|
||||||
|
|
||||||
assert_eq!(out[0], i16::MAX);
|
assert_eq!(out[0], i16::MAX);
|
||||||
assert_eq!(out[1], 0);
|
assert_eq!(out[1], 0);
|
||||||
@@ -117,35 +85,12 @@ mod tests {
|
|||||||
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
|
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
|
||||||
let mut out = [123_i16; 2];
|
let mut out = [123_i16; 2];
|
||||||
|
|
||||||
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 1.0, true);
|
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 1.0, true);
|
||||||
|
|
||||||
assert_eq!(out, [0, 0]);
|
assert_eq!(out, [0, 0]);
|
||||||
assert_eq!(stats, RenderDownmixStats::default());
|
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]
|
#[test]
|
||||||
fn downmix_f32_fills_missing_tail_with_silence() {
|
fn downmix_f32_fills_missing_tail_with_silence() {
|
||||||
let stereo = [1.0_f32, -1.0];
|
let stereo = [1.0_f32, -1.0];
|
||||||
@@ -155,45 +100,4 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(out, [0.0, 0.0]);
|
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,8 +1038,6 @@ pub struct BridgeAudioStats {
|
|||||||
pub frames_received: u32,
|
pub frames_received: u32,
|
||||||
/// Current push-to-talk state.
|
/// Current push-to-talk state.
|
||||||
pub ptt_active: bool,
|
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.
|
/// Bridge route class for P1 audio-processing policy.
|
||||||
@@ -1703,87 +1701,49 @@ pub enum BridgeEvent {
|
|||||||
},
|
},
|
||||||
/// Audio route changed (speaker/earpiece/BT/wired).
|
/// Audio route changed (speaker/earpiece/BT/wired).
|
||||||
AudioRouteChanged {
|
AudioRouteChanged {
|
||||||
/// New audio output route.
|
|
||||||
route: BridgeAudioRoute,
|
route: BridgeAudioRoute,
|
||||||
},
|
},
|
||||||
/// A client moved to a different channel.
|
|
||||||
ClientMoved {
|
ClientMoved {
|
||||||
/// Unique client identifier.
|
|
||||||
client_id: u64,
|
client_id: u64,
|
||||||
/// Destination channel.
|
|
||||||
new_channel_id: u64,
|
new_channel_id: u64,
|
||||||
},
|
},
|
||||||
/// A new client connected.
|
|
||||||
ClientJoined {
|
ClientJoined {
|
||||||
/// Unique client identifier.
|
|
||||||
client_id: u64,
|
client_id: u64,
|
||||||
/// Channel the client joined.
|
|
||||||
channel_id: u64,
|
channel_id: u64,
|
||||||
/// Display nickname.
|
|
||||||
name: String,
|
name: String,
|
||||||
/// Microphone muted state.
|
|
||||||
input_muted: bool,
|
input_muted: bool,
|
||||||
/// Speaker muted state.
|
|
||||||
output_muted: bool,
|
output_muted: bool,
|
||||||
/// True for server query (bot) clients.
|
|
||||||
is_server_query: bool,
|
is_server_query: bool,
|
||||||
/// Client's talk power value.
|
|
||||||
talk_power: i32,
|
talk_power: i32,
|
||||||
/// Whether the server granted temporary talk power.
|
|
||||||
talk_power_granted: bool,
|
talk_power_granted: bool,
|
||||||
},
|
},
|
||||||
/// A client disconnected.
|
|
||||||
ClientLeft {
|
ClientLeft {
|
||||||
/// Unique client identifier.
|
|
||||||
client_id: u64,
|
client_id: u64,
|
||||||
/// Display nickname at time of disconnect.
|
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
/// Client properties changed.
|
|
||||||
ClientUpdated {
|
ClientUpdated {
|
||||||
/// Unique client identifier.
|
|
||||||
client_id: u64,
|
client_id: u64,
|
||||||
/// Microphone muted state.
|
|
||||||
input_muted: bool,
|
input_muted: bool,
|
||||||
/// Speaker muted state.
|
|
||||||
output_muted: bool,
|
output_muted: bool,
|
||||||
/// True for server query (bot) clients.
|
|
||||||
is_server_query: bool,
|
is_server_query: bool,
|
||||||
/// Client's talk power value.
|
|
||||||
talk_power: i32,
|
talk_power: i32,
|
||||||
/// Whether the server granted temporary talk power.
|
|
||||||
talk_power_granted: bool,
|
talk_power_granted: bool,
|
||||||
},
|
},
|
||||||
/// A new channel appeared.
|
|
||||||
ChannelAdded {
|
ChannelAdded {
|
||||||
/// Unique channel identifier.
|
|
||||||
id: u64,
|
id: u64,
|
||||||
/// Parent channel ID.
|
|
||||||
parent: u64,
|
parent: u64,
|
||||||
/// Channel name.
|
|
||||||
name: String,
|
name: String,
|
||||||
/// Predecessor channel ID within the same parent (TeamSpeak
|
|
||||||
/// linked-list ordering hint). Zero means first child.
|
|
||||||
order: i64,
|
order: i64,
|
||||||
/// Whether the channel requires a password.
|
|
||||||
has_password: bool,
|
has_password: bool,
|
||||||
/// Talk power required to speak; `None` means no restriction.
|
|
||||||
needed_talk_power: Option<i32>,
|
needed_talk_power: Option<i32>,
|
||||||
},
|
},
|
||||||
/// A channel was deleted.
|
|
||||||
ChannelRemoved {
|
ChannelRemoved {
|
||||||
/// Channel identifier.
|
|
||||||
id: u64,
|
id: u64,
|
||||||
},
|
},
|
||||||
/// Channel properties changed.
|
|
||||||
ChannelUpdated {
|
ChannelUpdated {
|
||||||
/// Unique channel identifier.
|
|
||||||
id: u64,
|
id: u64,
|
||||||
/// Channel name.
|
|
||||||
name: String,
|
name: String,
|
||||||
/// Whether the channel requires a password.
|
|
||||||
has_password: bool,
|
has_password: bool,
|
||||||
/// Talk power required to speak; `None` means no restriction.
|
|
||||||
needed_talk_power: Option<i32>,
|
needed_talk_power: Option<i32>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1972,77 +1932,27 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
|
|||||||
route: route.into(),
|
route: route.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
chanora_core::SessionEvent::ClientMoved {
|
chanora_core::SessionEvent::ClientMoved { client_id, new_channel_id } => {
|
||||||
client_id,
|
BridgeEvent::ClientMoved { client_id, new_channel_id }
|
||||||
new_channel_id,
|
}
|
||||||
} => BridgeEvent::ClientMoved {
|
chanora_core::SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||||
client_id,
|
BridgeEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||||
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 } => {
|
chanora_core::SessionEvent::ClientLeft { client_id, name } => {
|
||||||
BridgeEvent::ClientLeft { client_id, name }
|
BridgeEvent::ClientLeft { client_id, name }
|
||||||
}
|
}
|
||||||
chanora_core::SessionEvent::ClientUpdated {
|
chanora_core::SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||||
client_id,
|
BridgeEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||||
input_muted,
|
}
|
||||||
output_muted,
|
chanora_core::SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
|
||||||
is_server_query,
|
BridgeEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
|
||||||
talk_power,
|
}
|
||||||
talk_power_granted,
|
chanora_core::SessionEvent::ChannelRemoved { id } => {
|
||||||
} => BridgeEvent::ClientUpdated {
|
BridgeEvent::ChannelRemoved { id }
|
||||||
client_id,
|
}
|
||||||
input_muted,
|
chanora_core::SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power } => {
|
||||||
output_muted,
|
BridgeEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2148,7 +2058,7 @@ pub fn events_stream(sink: StreamSink<BridgeEvent>) -> Result<(), BridgeError> {
|
|||||||
|
|
||||||
/// Read audio statistics. Errors if no connection or audio not started.
|
/// Read audio statistics. Errors if no connection or audio not started.
|
||||||
pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
||||||
let (s, r, p, lvl) = runtime()
|
let (s, r, p) = runtime()
|
||||||
.spawn(async { session().audio_stats().await })
|
.spawn(async { session().audio_stats().await })
|
||||||
.await
|
.await
|
||||||
.map_err(|e| task_join_error("audio_stats", e))??;
|
.map_err(|e| task_join_error("audio_stats", e))??;
|
||||||
@@ -2156,42 +2066,9 @@ pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
|||||||
frames_sent: s,
|
frames_sent: s,
|
||||||
frames_received: r,
|
frames_received: r,
|
||||||
ptt_active: p,
|
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.
|
/// Apply the P1 audio-processing config.
|
||||||
pub async fn set_audio_processing_config(
|
pub async fn set_audio_processing_config(
|
||||||
config: BridgeAudioProcessingConfig,
|
config: BridgeAudioProcessingConfig,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
|||||||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||||
);
|
);
|
||||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
||||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -20394775;
|
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 281698435;
|
||||||
|
|
||||||
// Section: executor
|
// Section: executor
|
||||||
|
|
||||||
@@ -738,42 +738,6 @@ 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(
|
fn wire__crate__api__is_connected_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
@@ -1826,14 +1790,6 @@ 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 {
|
impl SseDecode for String {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
@@ -2006,12 +1962,10 @@ impl SseDecode for crate::api::BridgeAudioStats {
|
|||||||
let mut var_framesSent = <u32>::sse_decode(deserializer);
|
let mut var_framesSent = <u32>::sse_decode(deserializer);
|
||||||
let mut var_framesReceived = <u32>::sse_decode(deserializer);
|
let mut var_framesReceived = <u32>::sse_decode(deserializer);
|
||||||
let mut var_pttActive = <bool>::sse_decode(deserializer);
|
let mut var_pttActive = <bool>::sse_decode(deserializer);
|
||||||
let mut var_inputLevel = <f32>::sse_decode(deserializer);
|
|
||||||
return crate::api::BridgeAudioStats {
|
return crate::api::BridgeAudioStats {
|
||||||
frames_sent: var_framesSent,
|
frames_sent: var_framesSent,
|
||||||
frames_received: var_framesReceived,
|
frames_received: var_framesReceived,
|
||||||
ptt_active: var_pttActive,
|
ptt_active: var_pttActive,
|
||||||
input_level: var_inputLevel,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2801,34 +2755,33 @@ fn pde_ffi_dispatcher_primary_impl(
|
|||||||
14 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
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),
|
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),
|
20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
|
||||||
21 => wire__crate__api__input_level_stream_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__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_audio_devices_impl(port, ptr, rust_vec_len, data_len),
|
23 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
|
||||||
24 => 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__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__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
|
27 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
|
||||||
28 => 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),
|
||||||
30 => 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_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_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_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_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 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
|
36 => {
|
||||||
37 => {
|
|
||||||
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
|
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
|
||||||
}
|
}
|
||||||
39 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
|
38 => 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),
|
39 => 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),
|
40 => 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),
|
41 => 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),
|
42 => 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),
|
43 => 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),
|
44 => 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),
|
45 => 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),
|
46 => 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),
|
47 => 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),
|
48 => 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),
|
49 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2850,10 +2803,10 @@ fn pde_ffi_dispatcher_sync_impl(
|
|||||||
data_len,
|
data_len,
|
||||||
),
|
),
|
||||||
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
|
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
|
||||||
25 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
|
24 => 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),
|
28 => 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),
|
30 => 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),
|
37 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3031,7 +2984,6 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioStats {
|
|||||||
self.frames_sent.into_into_dart().into_dart(),
|
self.frames_sent.into_into_dart().into_dart(),
|
||||||
self.frames_received.into_into_dart().into_dart(),
|
self.frames_received.into_into_dart().into_dart(),
|
||||||
self.ptt_active.into_into_dart().into_dart(),
|
self.ptt_active.into_into_dart().into_dart(),
|
||||||
self.input_level.into_into_dart().into_dart(),
|
|
||||||
]
|
]
|
||||||
.into_dart()
|
.into_dart()
|
||||||
}
|
}
|
||||||
@@ -3700,13 +3652,6 @@ 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 {
|
impl SseEncode for String {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
@@ -3836,7 +3781,6 @@ impl SseEncode for crate::api::BridgeAudioStats {
|
|||||||
<u32>::sse_encode(self.frames_sent, serializer);
|
<u32>::sse_encode(self.frames_sent, serializer);
|
||||||
<u32>::sse_encode(self.frames_received, serializer);
|
<u32>::sse_encode(self.frames_received, serializer);
|
||||||
<bool>::sse_encode(self.ptt_active, serializer);
|
<bool>::sse_encode(self.ptt_active, serializer);
|
||||||
<f32>::sse_encode(self.input_level, serializer);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
use std::collections::{HashSet, VecDeque};
|
use std::collections::HashSet;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -712,7 +712,7 @@ impl DiagnosticExport {
|
|||||||
/// diagnostic export and state-sync replay verification.
|
/// diagnostic export and state-sync replay verification.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ProtocolEventRecorder {
|
pub struct ProtocolEventRecorder {
|
||||||
events: VecDeque<String>,
|
events: Vec<String>,
|
||||||
capacity: usize,
|
capacity: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,20 +720,17 @@ impl ProtocolEventRecorder {
|
|||||||
/// Create a recorder with the given ring-buffer capacity.
|
/// Create a recorder with the given ring-buffer capacity.
|
||||||
pub fn new(capacity: usize) -> Self {
|
pub fn new(capacity: usize) -> Self {
|
||||||
Self {
|
Self {
|
||||||
events: VecDeque::with_capacity(capacity),
|
events: Vec::with_capacity(capacity),
|
||||||
capacity,
|
capacity,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push(&mut self, ts: &str, kind: &str, detail: &str) {
|
fn push(&mut self, ts: &str, kind: &str, detail: &str) {
|
||||||
if self.capacity == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let s = format!("[{ts}] {kind}: {detail}");
|
let s = format!("[{ts}] {kind}: {detail}");
|
||||||
if self.events.len() >= self.capacity {
|
if self.events.len() >= self.capacity {
|
||||||
self.events.pop_front();
|
self.events.remove(0);
|
||||||
}
|
}
|
||||||
self.events.push_back(s);
|
self.events.push(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a successful connection.
|
/// Record a successful connection.
|
||||||
@@ -780,12 +777,12 @@ impl ProtocolEventRecorder {
|
|||||||
|
|
||||||
/// Drain all recorded events and reset the buffer.
|
/// Drain all recorded events and reset the buffer.
|
||||||
pub fn drain(&mut self) -> Vec<String> {
|
pub fn drain(&mut self) -> Vec<String> {
|
||||||
self.events.drain(..).collect()
|
std::mem::take(&mut self.events)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot all recorded events without clearing the buffer.
|
/// Snapshot all recorded events without clearing the buffer.
|
||||||
pub fn snapshot(&self) -> Vec<String> {
|
pub fn snapshot(&self) -> Vec<String> {
|
||||||
self.events.iter().cloned().collect()
|
self.events.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1106,13 +1103,4 @@ mod tests {
|
|||||||
assert_eq!(first, second);
|
assert_eq!(first, second);
|
||||||
assert_eq!(drained, first);
|
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,9 +46,6 @@ use crate::ProtocolError;
|
|||||||
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
|
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
|
||||||
const INBOUND_VOICE_SEND_TIMEOUT: Duration = Duration::from_millis(40);
|
const INBOUND_VOICE_SEND_TIMEOUT: Duration = Duration::from_millis(40);
|
||||||
const PROFILE_REFRESH_RESULT_TIMEOUT: Duration = Duration::from_secs(3);
|
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<
|
type PendingMoves = HashMap<
|
||||||
MessageHandle,
|
MessageHandle,
|
||||||
@@ -59,13 +56,6 @@ 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)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
enum SendTimeoutError<T> {
|
enum SendTimeoutError<T> {
|
||||||
Timeout(T),
|
Timeout(T),
|
||||||
@@ -87,30 +77,6 @@ async fn send_with_timeout<T: Send>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn drain_voice_packets_for_tick<T, E>(
|
|
||||||
voice_out_rx: &mut mpsc::Receiver<T>,
|
|
||||||
max_packets: usize,
|
|
||||||
mut send: impl FnMut(T) -> Result<(), E>,
|
|
||||||
) -> usize {
|
|
||||||
let mut drained = 0;
|
|
||||||
for _ in 0..max_packets {
|
|
||||||
let packet = match voice_out_rx.try_recv() {
|
|
||||||
Ok(packet) => packet,
|
|
||||||
Err(_) => break,
|
|
||||||
};
|
|
||||||
let _ = send(packet);
|
|
||||||
drained += 1;
|
|
||||||
}
|
|
||||||
drained
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn bounded_drain_stream<S>(stream: S, timeout_duration: Duration)
|
|
||||||
where
|
|
||||||
S: futures::Stream,
|
|
||||||
{
|
|
||||||
let _ = tokio::time::timeout(timeout_duration, stream.for_each(|_| future::ready(()))).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pick the TeamSpeak `client_version`/platform/signature triple
|
/// Pick the TeamSpeak `client_version`/platform/signature triple
|
||||||
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
|
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
|
||||||
/// `tsproto-types` at vendor-time) that best matches the *runtime*
|
/// `tsproto-types` at vendor-time) that best matches the *runtime*
|
||||||
@@ -312,12 +278,10 @@ impl ProtocolClient {
|
|||||||
cfg.clone(),
|
cfg.clone(),
|
||||||
rx,
|
rx,
|
||||||
voice_out_rx,
|
voice_out_rx,
|
||||||
EventChannels {
|
voice_in_tx,
|
||||||
voice_in: voice_in_tx,
|
chat_tx,
|
||||||
chat: chat_tx,
|
activity_tx,
|
||||||
activity: activity_tx,
|
delta_tx,
|
||||||
delta: delta_tx,
|
|
||||||
},
|
|
||||||
ready_tx,
|
ready_tx,
|
||||||
lost_tx,
|
lost_tx,
|
||||||
));
|
));
|
||||||
@@ -368,21 +332,8 @@ impl ProtocolClient {
|
|||||||
/// Disconnect cleanly. Blocks until the task exits.
|
/// Disconnect cleanly. Blocks until the task exits.
|
||||||
pub async fn disconnect(self) {
|
pub async fn disconnect(self) {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
let request_path = async {
|
if self.tx.send(Request::Disconnect(tx)).await.is_ok() {
|
||||||
if self.tx.send(Request::Disconnect(tx)).await.is_ok() {
|
let _ = rx.await;
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,9 +471,6 @@ 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>> {
|
pub fn take_delta_rx(&self) -> Option<mpsc::Receiver<ProtocolDelta>> {
|
||||||
self.delta_rx.lock().ok().and_then(|mut g| g.take())
|
self.delta_rx.lock().ok().and_then(|mut g| g.take())
|
||||||
}
|
}
|
||||||
@@ -551,7 +499,10 @@ async fn connection_task(
|
|||||||
cfg: ConnectConfig,
|
cfg: ConnectConfig,
|
||||||
mut rx: mpsc::Receiver<Request>,
|
mut rx: mpsc::Receiver<Request>,
|
||||||
mut voice_out_rx: mpsc::Receiver<OutPacket>,
|
mut voice_out_rx: mpsc::Receiver<OutPacket>,
|
||||||
channels: EventChannels,
|
voice_in_tx: mpsc::Sender<InboundVoice>,
|
||||||
|
chat_tx: mpsc::Sender<ChatMessage>,
|
||||||
|
activity_tx: mpsc::Sender<ServerActivity>,
|
||||||
|
delta_tx: mpsc::Sender<ProtocolDelta>,
|
||||||
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
|
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
|
||||||
lost_tx: oneshot::Sender<DisconnectReason>,
|
lost_tx: oneshot::Sender<DisconnectReason>,
|
||||||
) {
|
) {
|
||||||
@@ -716,14 +667,12 @@ async fn connection_task(
|
|||||||
|
|
||||||
// Main loop: pump events, service requests, forward voice.
|
// Main loop: pump events, service requests, forward voice.
|
||||||
loop {
|
loop {
|
||||||
// 1. Send a bounded batch of outbound voice packets first — they're
|
// 1. Drain any outbound voice packets first — they're time-sensitive.
|
||||||
// time-sensitive, but control requests must still make progress.
|
while let Ok(pkt) = voice_out_rx.try_recv() {
|
||||||
drain_voice_packets_for_tick(&mut voice_out_rx, OUTBOUND_VOICE_PACKETS_PER_TICK, |pkt| {
|
|
||||||
if let Err(e) = con.send_audio(pkt) {
|
if let Err(e) = con.send_audio(pkt) {
|
||||||
warn!(target: "chanora_protocol", error = %e, "send_audio failed");
|
warn!(target: "chanora_protocol", error = %e, "send_audio failed");
|
||||||
}
|
}
|
||||||
Ok::<(), ()>(())
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Advance event stream by at most one event with a small timeout.
|
// 2. Advance event stream by at most one event with a small timeout.
|
||||||
let pump = async {
|
let pump = async {
|
||||||
@@ -731,19 +680,21 @@ async fn connection_task(
|
|||||||
tokio::time::timeout(Duration::from_millis(20), ev_stream.next()).await
|
tokio::time::timeout(Duration::from_millis(20), ev_stream.next()).await
|
||||||
};
|
};
|
||||||
match pump.await {
|
match pump.await {
|
||||||
Ok(Some(Ok(item))) => match item {
|
Ok(Some(Ok(item))) => {
|
||||||
StreamItem::Audio(buf) => {
|
match item {
|
||||||
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
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,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
other => handle_non_audio_stream_item(
|
}
|
||||||
&con,
|
|
||||||
other,
|
|
||||||
&channels.chat,
|
|
||||||
&channels.activity,
|
|
||||||
&channels.delta,
|
|
||||||
&mut pending_moves,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
Ok(Some(Err(e))) => {
|
Ok(Some(Err(e))) => {
|
||||||
warn!(target: "chanora_protocol", error = %e, "event error");
|
warn!(target: "chanora_protocol", error = %e, "event error");
|
||||||
// Some errors are transient; treat persistent ones
|
// Some errors are transient; treat persistent ones
|
||||||
@@ -776,8 +727,10 @@ async fn connection_task(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
for handle in expired {
|
for handle in expired {
|
||||||
if let Some((_target_channel, Some(reply), _)) = pending_moves.remove(&handle) {
|
if let Some((_target_channel, reply, _)) = pending_moves.remove(&handle) {
|
||||||
let _ = reply.send(Ok(()));
|
if let Some(reply) = reply {
|
||||||
|
let _ = reply.send(Ok(()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -838,7 +791,10 @@ async fn connection_task(
|
|||||||
let r = fetch_client_profile(
|
let r = fetch_client_profile(
|
||||||
&mut con,
|
&mut con,
|
||||||
client_id,
|
client_id,
|
||||||
&channels,
|
&voice_in_tx,
|
||||||
|
&chat_tx,
|
||||||
|
&activity_tx,
|
||||||
|
&delta_tx,
|
||||||
&mut pending_moves,
|
&mut pending_moves,
|
||||||
&mut voice_activity,
|
&mut voice_activity,
|
||||||
)
|
)
|
||||||
@@ -847,7 +803,7 @@ async fn connection_task(
|
|||||||
}
|
}
|
||||||
Ok(Request::Disconnect(reply)) => {
|
Ok(Request::Disconnect(reply)) => {
|
||||||
let _ = con.disconnect(DisconnectOptions::new());
|
let _ = con.disconnect(DisconnectOptions::new());
|
||||||
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
con.events().for_each(|_| future::ready(())).await;
|
||||||
let _ = reply.send(());
|
let _ = reply.send(());
|
||||||
info!(target: "chanora_protocol", "clean disconnect");
|
info!(target: "chanora_protocol", "clean disconnect");
|
||||||
exit!(DisconnectReason::UserRequested);
|
exit!(DisconnectReason::UserRequested);
|
||||||
@@ -855,7 +811,7 @@ async fn connection_task(
|
|||||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||||
let _ = con.disconnect(DisconnectOptions::new());
|
let _ = con.disconnect(DisconnectOptions::new());
|
||||||
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
con.events().for_each(|_| future::ready(())).await;
|
||||||
info!(target: "chanora_protocol", "handle dropped; implicit disconnect");
|
info!(target: "chanora_protocol", "handle dropped; implicit disconnect");
|
||||||
exit!(DisconnectReason::UserRequested);
|
exit!(DisconnectReason::UserRequested);
|
||||||
}
|
}
|
||||||
@@ -913,7 +869,7 @@ fn handle_non_audio_stream_item(
|
|||||||
} = &ev
|
} = &ev
|
||||||
{
|
{
|
||||||
let own_client = con.get_state().ok().map(|state| state.own_client);
|
let own_client = con.get_state().ok().map(|state| state.own_client);
|
||||||
if let Ok(state) = con.get_state() {
|
if let Some(state) = con.get_state().ok() {
|
||||||
if let Some(client) = state.clients.get(client_id) {
|
if let Some(client) = state.clients.get(client_id) {
|
||||||
let _ = delta_tx.try_send(ProtocolDelta::ClientMoved {
|
let _ = delta_tx.try_send(ProtocolDelta::ClientMoved {
|
||||||
client_id: client_id.0 as u64,
|
client_id: client_id.0 as u64,
|
||||||
@@ -967,9 +923,7 @@ fn handle_non_audio_stream_item(
|
|||||||
let mapped = match target {
|
let mapped = match target {
|
||||||
tsclientlib::MessageTarget::Server => MessageTarget::Server,
|
tsclientlib::MessageTarget::Server => MessageTarget::Server,
|
||||||
tsclientlib::MessageTarget::Channel => MessageTarget::Channel,
|
tsclientlib::MessageTarget::Channel => MessageTarget::Channel,
|
||||||
tsclientlib::MessageTarget::Client(id) => {
|
tsclientlib::MessageTarget::Client(id) => MessageTarget::Client(id.0 as u64),
|
||||||
MessageTarget::Client(id.0 as u64)
|
|
||||||
}
|
|
||||||
tsclientlib::MessageTarget::Poke(id) => MessageTarget::Poke(id.0 as u64),
|
tsclientlib::MessageTarget::Poke(id) => MessageTarget::Poke(id.0 as u64),
|
||||||
};
|
};
|
||||||
let _ = chat_tx.try_send(ChatMessage {
|
let _ = chat_tx.try_send(ChatMessage {
|
||||||
@@ -1161,21 +1115,16 @@ fn send_text_to_mode(
|
|||||||
async fn fetch_client_profile(
|
async fn fetch_client_profile(
|
||||||
con: &mut Connection,
|
con: &mut Connection,
|
||||||
client_id: u64,
|
client_id: u64,
|
||||||
channels: &EventChannels,
|
voice_in_tx: &mpsc::Sender<InboundVoice>,
|
||||||
|
chat_tx: &mpsc::Sender<ChatMessage>,
|
||||||
|
activity_tx: &mpsc::Sender<ServerActivity>,
|
||||||
|
delta_tx: &mpsc::Sender<ProtocolDelta>,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
) -> Result<ClientProfile, ProtocolError> {
|
) -> Result<ClientProfile, ProtocolError> {
|
||||||
let target_id = TsClientId(client_id as u16);
|
let target_id = TsClientId(client_id as u16);
|
||||||
|
|
||||||
let (
|
let (database_id, uid_b64, has_optional, has_connection, is_own, needs_server_groups, needs_channel_groups) = {
|
||||||
database_id,
|
|
||||||
uid_b64,
|
|
||||||
has_optional,
|
|
||||||
has_connection,
|
|
||||||
is_own,
|
|
||||||
needs_server_groups,
|
|
||||||
needs_channel_groups,
|
|
||||||
) = {
|
|
||||||
let state = con
|
let state = con
|
||||||
.get_state()
|
.get_state()
|
||||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||||
@@ -1206,7 +1155,10 @@ async fn fetch_client_profile(
|
|||||||
let _ = request_messages(
|
let _ = request_messages(
|
||||||
con,
|
con,
|
||||||
build_command("servergrouplist", &[], &[]),
|
build_command("servergrouplist", &[], &[]),
|
||||||
channels,
|
voice_in_tx,
|
||||||
|
chat_tx,
|
||||||
|
activity_tx,
|
||||||
|
delta_tx,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
)
|
)
|
||||||
@@ -1216,7 +1168,10 @@ async fn fetch_client_profile(
|
|||||||
let _ = request_messages(
|
let _ = request_messages(
|
||||||
con,
|
con,
|
||||||
build_command("channelgrouplist", &[], &[]),
|
build_command("channelgrouplist", &[], &[]),
|
||||||
channels,
|
voice_in_tx,
|
||||||
|
chat_tx,
|
||||||
|
activity_tx,
|
||||||
|
delta_tx,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
)
|
)
|
||||||
@@ -1230,7 +1185,10 @@ async fn fetch_client_profile(
|
|||||||
&[("clid", client_id.to_string())],
|
&[("clid", client_id.to_string())],
|
||||||
&[],
|
&[],
|
||||||
),
|
),
|
||||||
channels,
|
voice_in_tx,
|
||||||
|
chat_tx,
|
||||||
|
activity_tx,
|
||||||
|
delta_tx,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
)
|
)
|
||||||
@@ -1248,7 +1206,10 @@ async fn fetch_client_profile(
|
|||||||
if let Err(e) = request_messages(
|
if let Err(e) = request_messages(
|
||||||
con,
|
con,
|
||||||
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
|
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
|
||||||
channels,
|
voice_in_tx,
|
||||||
|
chat_tx,
|
||||||
|
activity_tx,
|
||||||
|
delta_tx,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
)
|
)
|
||||||
@@ -1264,9 +1225,18 @@ async fn fetch_client_profile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let db_info = if refresh_plan.needs_client_db_info {
|
let db_info = if refresh_plan.needs_client_db_info {
|
||||||
request_client_db_info(con, database_id, channels, pending_moves, voice_activity)
|
request_client_db_info(
|
||||||
.await
|
con,
|
||||||
.ok()
|
database_id,
|
||||||
|
voice_in_tx,
|
||||||
|
chat_tx,
|
||||||
|
activity_tx,
|
||||||
|
delta_tx,
|
||||||
|
pending_moves,
|
||||||
|
voice_activity,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -1333,18 +1303,10 @@ async fn fetch_client_profile(
|
|||||||
.or_else(|| db_info.as_ref().map(|info| info.created.unix_timestamp())),
|
.or_else(|| db_info.as_ref().map(|info| info.created.unix_timestamp())),
|
||||||
last_connected_unix_seconds: optional
|
last_connected_unix_seconds: optional
|
||||||
.map(|info| info.last_connected.unix_timestamp())
|
.map(|info| info.last_connected.unix_timestamp())
|
||||||
.or_else(|| {
|
.or_else(|| db_info.as_ref().map(|info| info.last_connected.unix_timestamp())),
|
||||||
db_info
|
|
||||||
.as_ref()
|
|
||||||
.map(|info| info.last_connected.unix_timestamp())
|
|
||||||
}),
|
|
||||||
connections_total: optional
|
connections_total: optional
|
||||||
.map(|info| u64::from(info.connections_total))
|
.map(|info| u64::from(info.connections_total))
|
||||||
.or_else(|| {
|
.or_else(|| db_info.as_ref().map(|info| u64::from(info.connections_total))),
|
||||||
db_info
|
|
||||||
.as_ref()
|
|
||||||
.map(|info| u64::from(info.connections_total))
|
|
||||||
}),
|
|
||||||
online_seconds: connection
|
online_seconds: connection
|
||||||
.and_then(|info| info.connected_time.map(|duration| duration.whole_seconds())),
|
.and_then(|info| info.connected_time.map(|duration| duration.whole_seconds())),
|
||||||
idle_milliseconds: connection.map(|info| duration_millis(info.idle_time)),
|
idle_milliseconds: connection.map(|info| duration_millis(info.idle_time)),
|
||||||
@@ -1377,10 +1339,14 @@ async fn fetch_client_profile(
|
|||||||
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_total)),
|
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_total)),
|
||||||
packet_loss_client_to_server_total: net_stats
|
packet_loss_client_to_server_total: net_stats
|
||||||
.map(|s| s.get_packetloss())
|
.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
|
packet_loss_server_to_client_total: net_stats
|
||||||
.map(|s| s.get_packetloss_s2c_total())
|
.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)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1423,7 +1389,10 @@ fn client_profile_refresh_plan(
|
|||||||
async fn request_messages(
|
async fn request_messages(
|
||||||
con: &mut Connection,
|
con: &mut Connection,
|
||||||
command: OutCommand,
|
command: OutCommand,
|
||||||
channels: &EventChannels,
|
voice_in_tx: &mpsc::Sender<InboundVoice>,
|
||||||
|
chat_tx: &mpsc::Sender<ChatMessage>,
|
||||||
|
activity_tx: &mpsc::Sender<ServerActivity>,
|
||||||
|
delta_tx: &mpsc::Sender<ProtocolDelta>,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
) -> Result<Vec<InMessage>, ProtocolError> {
|
) -> Result<Vec<InMessage>, ProtocolError> {
|
||||||
@@ -1459,14 +1428,14 @@ async fn request_messages(
|
|||||||
return Ok(messages);
|
return Ok(messages);
|
||||||
}
|
}
|
||||||
StreamItem::Audio(buf) => {
|
StreamItem::Audio(buf) => {
|
||||||
handle_audio_stream_item(&channels.voice_in, voice_activity, buf).await;
|
handle_audio_stream_item(voice_in_tx, voice_activity, buf).await;
|
||||||
}
|
}
|
||||||
other => handle_non_audio_stream_item(
|
other => handle_non_audio_stream_item(
|
||||||
con,
|
con,
|
||||||
other,
|
other,
|
||||||
&channels.chat,
|
chat_tx,
|
||||||
&channels.activity,
|
activity_tx,
|
||||||
&channels.delta,
|
delta_tx,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -1476,14 +1445,20 @@ async fn request_messages(
|
|||||||
async fn request_client_db_info(
|
async fn request_client_db_info(
|
||||||
con: &mut Connection,
|
con: &mut Connection,
|
||||||
dbid: tsclientlib::ClientDbId,
|
dbid: tsclientlib::ClientDbId,
|
||||||
channels: &EventChannels,
|
voice_in_tx: &mpsc::Sender<InboundVoice>,
|
||||||
|
chat_tx: &mpsc::Sender<ChatMessage>,
|
||||||
|
activity_tx: &mpsc::Sender<ServerActivity>,
|
||||||
|
delta_tx: &mpsc::Sender<ProtocolDelta>,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
) -> Result<InClientDbInfoPart, ProtocolError> {
|
) -> Result<InClientDbInfoPart, ProtocolError> {
|
||||||
let messages = request_messages(
|
let messages = request_messages(
|
||||||
con,
|
con,
|
||||||
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
|
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
|
||||||
channels,
|
voice_in_tx,
|
||||||
|
chat_tx,
|
||||||
|
activity_tx,
|
||||||
|
delta_tx,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
)
|
)
|
||||||
@@ -1806,7 +1781,9 @@ fn format_server_activity(con: &Connection, ev: &tsclientlib::events::Event) ->
|
|||||||
extra,
|
extra,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
extra.reason?;
|
if extra.reason.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let client = activity_client(con, *client_id)?;
|
let client = activity_client(con, *client_id)?;
|
||||||
let channel = activity_channel_name(con, client.channel)?;
|
let channel = activity_channel_name(con, client.channel)?;
|
||||||
Some(format!(
|
Some(format!(
|
||||||
@@ -1924,12 +1901,10 @@ const _: () = {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
bounded_drain_stream, client_profile_refresh_plan, drain_voice_packets_for_tick,
|
client_profile_refresh_plan, is_server_query_client_type, send_with_timeout,
|
||||||
is_server_query_client_type, send_with_timeout, server_socket_from_config,
|
server_socket_from_config, sort_channels_tree_by, std_duration_millis,
|
||||||
sort_channels_tree_by, std_duration_millis, ConnectConfig, ProtocolClient, Request,
|
ConnectConfig, SendTimeoutError,
|
||||||
SendTimeoutError, DISCONNECT_REPLY_TIMEOUT,
|
|
||||||
};
|
};
|
||||||
use futures::stream;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tsproto_types::ClientType;
|
use tsproto_types::ClientType;
|
||||||
@@ -2172,83 +2147,6 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(result, Err(SendTimeoutError::Timeout(2)));
|
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(
|
fn forward_delta(
|
||||||
@@ -2263,7 +2161,7 @@ fn forward_delta(
|
|||||||
id: PropertyId::Client(client_id),
|
id: PropertyId::Client(client_id),
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
if let Ok(state) = con.get_state() {
|
if let Some(state) = con.get_state().ok() {
|
||||||
if let Some(client) = state.clients.get(client_id) {
|
if let Some(client) = state.clients.get(client_id) {
|
||||||
let _ = delta_tx.try_send(ProtocolDelta::ClientJoined {
|
let _ = delta_tx.try_send(ProtocolDelta::ClientJoined {
|
||||||
client_id: client_id.0 as u64,
|
client_id: client_id.0 as u64,
|
||||||
@@ -2280,13 +2178,15 @@ fn forward_delta(
|
|||||||
}
|
}
|
||||||
Event::PropertyRemoved {
|
Event::PropertyRemoved {
|
||||||
id: PropertyId::Client(_),
|
id: PropertyId::Client(_),
|
||||||
old: PropertyValue::Client(client),
|
old,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let _ = delta_tx.try_send(ProtocolDelta::ClientLeft {
|
if let PropertyValue::Client(client) = old {
|
||||||
client_id: client.id.0 as u64,
|
let _ = delta_tx.try_send(ProtocolDelta::ClientLeft {
|
||||||
name: client.name.clone(),
|
client_id: client.id.0 as u64,
|
||||||
});
|
name: client.name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Event::PropertyChanged {
|
Event::PropertyChanged {
|
||||||
id: PropertyId::ClientChannel(_),
|
id: PropertyId::ClientChannel(_),
|
||||||
@@ -2296,7 +2196,7 @@ fn forward_delta(
|
|||||||
id: PropertyId::Client(client_id),
|
id: PropertyId::Client(client_id),
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
if let Ok(state) = con.get_state() {
|
if let Some(state) = con.get_state().ok() {
|
||||||
if let Some(client) = state.clients.get(client_id) {
|
if let Some(client) = state.clients.get(client_id) {
|
||||||
let _ = delta_tx.try_send(ProtocolDelta::ClientUpdated {
|
let _ = delta_tx.try_send(ProtocolDelta::ClientUpdated {
|
||||||
client_id: client_id.0 as u64,
|
client_id: client_id.0 as u64,
|
||||||
@@ -2313,7 +2213,7 @@ fn forward_delta(
|
|||||||
id: PropertyId::Channel(channel_id),
|
id: PropertyId::Channel(channel_id),
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
if let Ok(state) = con.get_state() {
|
if let Some(state) = con.get_state().ok() {
|
||||||
if let Some(channel) = state.channels.get(channel_id) {
|
if let Some(channel) = state.channels.get(channel_id) {
|
||||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelAdded {
|
let _ = delta_tx.try_send(ProtocolDelta::ChannelAdded {
|
||||||
id: channel_id.0,
|
id: channel_id.0,
|
||||||
@@ -2328,16 +2228,20 @@ fn forward_delta(
|
|||||||
}
|
}
|
||||||
Event::PropertyRemoved {
|
Event::PropertyRemoved {
|
||||||
id: PropertyId::Channel(_),
|
id: PropertyId::Channel(_),
|
||||||
old: PropertyValue::Channel(channel),
|
old,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved { id: channel.id.0 });
|
if let PropertyValue::Channel(channel) = old {
|
||||||
|
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved {
|
||||||
|
id: channel.id.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Event::PropertyChanged {
|
Event::PropertyChanged {
|
||||||
id: PropertyId::Channel(channel_id),
|
id: PropertyId::Channel(channel_id),
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
if let Ok(state) = con.get_state() {
|
if let Some(state) = con.get_state().ok() {
|
||||||
if let Some(channel) = state.channels.get(channel_id) {
|
if let Some(channel) = state.channels.get(channel_id) {
|
||||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelUpdated {
|
let _ = delta_tx.try_send(ProtocolDelta::ChannelUpdated {
|
||||||
id: channel_id.0,
|
id: channel_id.0,
|
||||||
|
|||||||
@@ -168,93 +168,52 @@ pub struct ServerSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelId {
|
impl ChannelId {
|
||||||
/// Root/top-level channel identifier.
|
|
||||||
pub const ROOT: ChannelId = ChannelId(0);
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum ProtocolDelta {
|
pub enum ProtocolDelta {
|
||||||
/// A client moved to a different channel.
|
|
||||||
ClientMoved {
|
ClientMoved {
|
||||||
/// Unique client identifier.
|
|
||||||
client_id: u64,
|
client_id: u64,
|
||||||
/// Destination channel identifier.
|
|
||||||
new_channel_id: u64,
|
new_channel_id: u64,
|
||||||
},
|
},
|
||||||
/// A new client appeared on the server.
|
|
||||||
ClientJoined {
|
ClientJoined {
|
||||||
/// Unique client identifier.
|
|
||||||
client_id: u64,
|
client_id: u64,
|
||||||
/// Channel the client joined.
|
|
||||||
channel_id: u64,
|
channel_id: u64,
|
||||||
/// Display nickname.
|
|
||||||
name: String,
|
name: String,
|
||||||
/// Microphone muted state.
|
|
||||||
input_muted: bool,
|
input_muted: bool,
|
||||||
/// Speaker muted state.
|
|
||||||
output_muted: bool,
|
output_muted: bool,
|
||||||
/// Whether the client is a server query client.
|
|
||||||
is_server_query: bool,
|
is_server_query: bool,
|
||||||
/// Client's talk power value.
|
|
||||||
talk_power: i32,
|
talk_power: i32,
|
||||||
/// Whether the server granted temporary talk power.
|
|
||||||
talk_power_granted: bool,
|
talk_power_granted: bool,
|
||||||
},
|
},
|
||||||
/// A client disconnected.
|
|
||||||
ClientLeft {
|
ClientLeft {
|
||||||
/// Unique client identifier.
|
|
||||||
client_id: u64,
|
client_id: u64,
|
||||||
/// Display nickname.
|
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
/// A client's properties changed.
|
|
||||||
ClientUpdated {
|
ClientUpdated {
|
||||||
/// Unique client identifier.
|
|
||||||
client_id: u64,
|
client_id: u64,
|
||||||
/// Microphone muted state.
|
|
||||||
input_muted: bool,
|
input_muted: bool,
|
||||||
/// Speaker muted state.
|
|
||||||
output_muted: bool,
|
output_muted: bool,
|
||||||
/// Whether the client is a server query client.
|
|
||||||
is_server_query: bool,
|
is_server_query: bool,
|
||||||
/// Client's talk power value.
|
|
||||||
talk_power: i32,
|
talk_power: i32,
|
||||||
/// Whether the server granted temporary talk power.
|
|
||||||
talk_power_granted: bool,
|
talk_power_granted: bool,
|
||||||
},
|
},
|
||||||
/// A new channel appeared.
|
|
||||||
ChannelAdded {
|
ChannelAdded {
|
||||||
/// Channel identifier.
|
|
||||||
id: u64,
|
id: u64,
|
||||||
/// Parent channel identifier.
|
|
||||||
parent: u64,
|
parent: u64,
|
||||||
/// Channel name.
|
|
||||||
name: String,
|
name: String,
|
||||||
/// Predecessor channel ID within the same parent (TeamSpeak
|
|
||||||
/// linked-list ordering hint). Zero means first child.
|
|
||||||
order: i64,
|
order: i64,
|
||||||
/// Whether the channel has a password.
|
|
||||||
has_password: bool,
|
has_password: bool,
|
||||||
/// Needed talk power, or `None` when unrestricted.
|
|
||||||
needed_talk_power: Option<i32>,
|
needed_talk_power: Option<i32>,
|
||||||
},
|
},
|
||||||
/// A channel was deleted.
|
|
||||||
ChannelRemoved {
|
ChannelRemoved {
|
||||||
/// Channel identifier.
|
|
||||||
id: u64,
|
id: u64,
|
||||||
},
|
},
|
||||||
/// Channel properties changed.
|
|
||||||
ChannelUpdated {
|
ChannelUpdated {
|
||||||
/// Channel identifier.
|
|
||||||
id: u64,
|
id: u64,
|
||||||
/// Channel name.
|
|
||||||
name: String,
|
name: String,
|
||||||
/// Whether the channel has a password.
|
|
||||||
has_password: bool,
|
has_password: bool,
|
||||||
/// Needed talk power, or `None` when unrestricted.
|
|
||||||
needed_talk_power: Option<i32>,
|
needed_talk_power: Option<i32>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chanora_resolver"
|
name = "chanora_resolver"
|
||||||
version.workspace = true
|
version = "0.1.0"
|
||||||
edition.workspace = true
|
edition = "2021"
|
||||||
license.workspace = true
|
license = "MIT OR Apache-2.0"
|
||||||
publish.workspace = true
|
publish = false
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -346,7 +346,9 @@ pub fn reduce(state: &mut Option<ServerState>, event: StateEvent) -> Reduction {
|
|||||||
if removed_channel {
|
if removed_channel {
|
||||||
deltas.push(Delta::ChannelRemoved(id));
|
deltas.push(Delta::ChannelRemoved(id));
|
||||||
}
|
}
|
||||||
Reduction { deltas }
|
Reduction {
|
||||||
|
deltas,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => Reduction { deltas: vec![] },
|
_ => Reduction { deltas: vec![] },
|
||||||
},
|
},
|
||||||
@@ -410,7 +412,14 @@ pub fn reduce_reconnect_snapshot(
|
|||||||
state: &mut Option<ServerState>,
|
state: &mut Option<ServerState>,
|
||||||
snap: ServerSnapshot,
|
snap: ServerSnapshot,
|
||||||
) -> Reduction {
|
) -> Reduction {
|
||||||
reduce(state, StateEvent::Snapshot(snap))
|
let normalized = normalize_snapshot(snap);
|
||||||
|
*state = Some(ServerState::from_snapshot(normalized.clone()));
|
||||||
|
Reduction {
|
||||||
|
deltas: vec![
|
||||||
|
Delta::ConnectionStateChanged(ConnectionState::Ready),
|
||||||
|
Delta::SnapshotApplied(normalized),
|
||||||
|
],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -628,12 +628,6 @@ fn read_file_dek(path: &Path) -> Result<[u8; 32], StorageError> {
|
|||||||
/// headless / sandboxed environments force the file-fallback path
|
/// headless / sandboxed environments force the file-fallback path
|
||||||
/// without poking a real OS keyring (which would either prompt the
|
/// without poking a real OS keyring (which would either prompt the
|
||||||
/// user or block on a missing D-Bus session).
|
/// user or block on a missing D-Bus session).
|
||||||
#[cfg(any(
|
|
||||||
target_os = "linux",
|
|
||||||
target_os = "macos",
|
|
||||||
target_os = "windows",
|
|
||||||
target_os = "ios"
|
|
||||||
))]
|
|
||||||
fn keyring_disabled() -> bool {
|
fn keyring_disabled() -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
std::env::var("CHANORA_DISABLE_KEYRING").as_deref(),
|
std::env::var("CHANORA_DISABLE_KEYRING").as_deref(),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user