Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
861e915b81 |
-11
@@ -124,16 +124,5 @@ opencode.json
|
|||||||
# pure waste — the committed binary was ~40 MB per commit).
|
# pure waste — the committed binary was ~40 MB per commit).
|
||||||
/apps/chanora_flutter/macos/Frameworks/
|
/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
+75
-73
@@ -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"
|
||||||
@@ -437,12 +428,12 @@ dependencies = [
|
|||||||
"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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/haptic_kit/ios"
|
:path: ".symlinks/plugins/haptic_kit/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
chanora_bridge: 26252acdf9ca660ce9c132ad25cd5ad5af467b16
|
chanora_bridge: 2ed7c2ba427fab135dd9eab66c507b09cfee113a
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
||||||
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
||||||
|
|
||||||
PODFILE CHECKSUM: 85b93b53f958f1ff700a147e9da4374c8b1c6970
|
PODFILE CHECKSUM: e2123068539aeb66d53dc1612b383d13f489ede2
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -8,19 +8,19 @@
|
|||||||
|
|
||||||
/* 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 */; };
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -48,10 +48,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 +60,20 @@
|
|||||||
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>"; };
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -86,7 +83,7 @@
|
|||||||
files = (
|
files = (
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
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 +109,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 +124,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>";
|
||||||
@@ -161,7 +155,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 +201,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 +218,13 @@
|
|||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
name = Chanora;
|
name = Runner;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
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 +257,14 @@
|
|||||||
);
|
);
|
||||||
mainGroup = 97C146E51CF9000F007C117D;
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/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 +337,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 +363,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 +385,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 +412,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 +496,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 +528,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 +544,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 +560,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 +682,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 +708,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 +749,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,26 +762,26 @@
|
|||||||
/* 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;
|
||||||
|
};
|
||||||
|
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 */
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = FlutterGeneratedPluginSwiftPackage;
|
|
||||||
};
|
|
||||||
8C5000032DD0000000000001 /* SileroCoreML */ = {
|
8C5000032DD0000000000001 /* SileroCoreML */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
|
package = 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
|
||||||
productName = SileroCoreML;
|
productName = SileroCoreML;
|
||||||
};
|
};
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = FlutterGeneratedPluginSwiftPackage;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?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">
|
||||||
@@ -15,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>
|
||||||
</EnvironmentBuildable>
|
</EnvironmentBuildable>
|
||||||
@@ -33,8 +33,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 +50,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 +86,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 +103,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, .mixWithOthers]
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||||
)
|
)
|
||||||
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-voip")
|
logAudioSessionState(context: "mediaServicesWereReset")
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
||||||
}
|
}
|
||||||
} else {
|
// P1: After rebuilding the session, send the current route class to
|
||||||
do {
|
// Rust so it can recompute the processing policy and reset the
|
||||||
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
|
||||||
logAudioSessionState(context: "mediaServicesWereReset-ambient")
|
// which calls ios_restart_voice_unit.
|
||||||
} catch {
|
|
||||||
NSLog("chanora_flutter: AVAudioSession media-services reset ambient restore failed: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,16 +210,12 @@ 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
|
||||||
@@ -274,26 +236,7 @@ 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",
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
"retryAction": "重试",
|
"retryAction": "重试",
|
||||||
"chatAction": "聊天",
|
"chatAction": "聊天",
|
||||||
"chatCloseAction": "关闭聊天",
|
"chatCloseAction": "关闭聊天",
|
||||||
"chatPanelCollapsedHint": "点击聊天按钮以继续对话",
|
|
||||||
"chatNewPrivateAction": "新建私聊",
|
"chatNewPrivateAction": "新建私聊",
|
||||||
"chatSearchClientsHint": "搜索用户...",
|
"chatSearchClientsHint": "搜索用户...",
|
||||||
"chatDirectMessageAction": "私聊",
|
"chatDirectMessageAction": "私聊",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 => '新建私聊';
|
||||||
|
|
||||||
|
|||||||
+105
-305
@@ -16,16 +16,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||||
|
|
||||||
import 'design/breakpoints.dart';
|
|
||||||
import 'design/viewport_info.dart';
|
|
||||||
import 'l10n/generated/app_localizations.dart';
|
import 'l10n/generated/app_localizations.dart';
|
||||||
import 'services/android_permissions_service.dart';
|
import 'services/android_permissions_service.dart';
|
||||||
import 'services/app_bootstrap.dart';
|
import 'services/app_bootstrap.dart';
|
||||||
import 'services/audio_lifecycle_service.dart';
|
import 'services/audio_lifecycle_service.dart';
|
||||||
import 'services/ios_audio_session_controller.dart';
|
|
||||||
import 'services/channel_join_error_mapper.dart';
|
import 'services/channel_join_error_mapper.dart';
|
||||||
import 'services/connection_phase_state.dart';
|
import 'services/connection_phase_state.dart';
|
||||||
import 'services/hard_mute_owners.dart';
|
|
||||||
import 'services/ios_permissions_service.dart';
|
import 'services/ios_permissions_service.dart';
|
||||||
import 'services/macos_permissions_service.dart';
|
import 'services/macos_permissions_service.dart';
|
||||||
import 'services/prefetch_debouncer.dart';
|
import 'services/prefetch_debouncer.dart';
|
||||||
@@ -35,14 +31,12 @@ import 'services/ui_preferences_service.dart';
|
|||||||
import 'src/rust/api.dart' as rust;
|
import 'src/rust/api.dart' as rust;
|
||||||
import 'src/rust/frb_generated.dart';
|
import 'src/rust/frb_generated.dart';
|
||||||
import 'src/rust/lib.dart' as rust_err;
|
import 'src/rust/lib.dart' as rust_err;
|
||||||
|
import 'widgets/permission_state_banner.dart';
|
||||||
import 'widgets/audio_processing_config_state.dart';
|
import 'widgets/audio_processing_config_state.dart';
|
||||||
import 'widgets/audio_debug_stats_panel.dart';
|
|
||||||
import 'widgets/chat_panel.dart';
|
|
||||||
import 'widgets/chat_views.dart';
|
import 'widgets/chat_views.dart';
|
||||||
import 'widgets/client_info_sheet.dart';
|
import 'widgets/client_info_sheet.dart';
|
||||||
import 'widgets/connect_widgets.dart';
|
import 'widgets/connect_widgets.dart';
|
||||||
import 'widgets/input_dialogs.dart';
|
import 'widgets/input_dialogs.dart';
|
||||||
import 'widgets/permission_state_banner.dart';
|
|
||||||
import 'widgets/snapshot_view.dart';
|
import 'widgets/snapshot_view.dart';
|
||||||
import 'widgets/voice_platform.dart';
|
import 'widgets/voice_platform.dart';
|
||||||
import 'widgets/voice_bar.dart';
|
import 'widgets/voice_bar.dart';
|
||||||
@@ -51,11 +45,8 @@ import 'widgets/voice_settings.dart';
|
|||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
|
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
|
||||||
// Debug-only overlay. `kDebugMode` is a compile-time const in
|
|
||||||
// release builds (= false), so the overlay subtree is tree-shaken
|
const MethodChannel _iosPlatformChannel = MethodChannel('chanora/ios_platform');
|
||||||
// out of release/profile binaries entirely — release users never
|
|
||||||
// see internal audio stats and we don't pay the render cost.
|
|
||||||
bool get _showAudioDebugOverlay => kDebugMode && _isMacOS;
|
|
||||||
|
|
||||||
const Color _appSurfaceColor = Color(0xFFFFFBFE);
|
const Color _appSurfaceColor = Color(0xFFFFFBFE);
|
||||||
|
|
||||||
@@ -159,15 +150,10 @@ class _ChanoraAppState extends State<ChanoraApp> {
|
|||||||
themeMode: _themeMode,
|
themeMode: _themeMode,
|
||||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
supportedLocales: AppL10n.supportedLocales,
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
home: Stack(
|
home: _BetaHome(
|
||||||
children: [
|
|
||||||
_BetaHome(
|
|
||||||
themeMode: _themeMode,
|
themeMode: _themeMode,
|
||||||
onThemeModeChanged: _setThemeMode,
|
onThemeModeChanged: _setThemeMode,
|
||||||
),
|
),
|
||||||
if (_showAudioDebugOverlay) const AudioDebugStatsPanel(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -315,6 +301,8 @@ class _ReceivedPoke {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||||
|
static const _wideBreakpoint = 600.0;
|
||||||
|
|
||||||
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
|
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
|
||||||
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
|
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
|
||||||
final _passwordCtl = TextEditingController();
|
final _passwordCtl = TextEditingController();
|
||||||
@@ -340,9 +328,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
bool _inChannel = false;
|
bool _inChannel = false;
|
||||||
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
|
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
|
||||||
bool _hardMute = false;
|
bool _hardMute = false;
|
||||||
HardMuteOwners _hardMuteOwners = const HardMuteOwners();
|
bool _hardMuteByPermission = false;
|
||||||
bool get _hardMuteByPermission => _hardMuteOwners.permission;
|
bool _hardMuteByTalkPower = false;
|
||||||
bool get _hardMuteByTalkPower => _hardMuteOwners.talkPower;
|
|
||||||
bool _permissionHardMuteClearInFlight = false;
|
bool _permissionHardMuteClearInFlight = false;
|
||||||
int _releaseTailMs = 200;
|
int _releaseTailMs = 200;
|
||||||
BigInt? _currentVoiceChannelId;
|
BigInt? _currentVoiceChannelId;
|
||||||
@@ -381,37 +368,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
|
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
|
||||||
int _chatUnread = 0;
|
int _chatUnread = 0;
|
||||||
bool _chatOpen = false;
|
bool _chatOpen = false;
|
||||||
rust.BridgeMessageTarget? _inlineChatTarget;
|
|
||||||
String _inlineChatClientName = '';
|
|
||||||
bool _inlineChatCollapseNoticeShown = false;
|
|
||||||
|
|
||||||
/// Per-target draft text. Populated when switching away from a conversation
|
|
||||||
/// so the user's unfinished message is preserved.
|
|
||||||
final Map<String, String> _chatDrafts = {};
|
|
||||||
|
|
||||||
/// Channel IDs that have unread chat messages (used for dot indicators).
|
|
||||||
Set<BigInt> get _unreadChannelIds {
|
|
||||||
if (_chatOpen) return const {};
|
|
||||||
final ids = <BigInt>{};
|
|
||||||
for (final entry in _chatMessages) {
|
|
||||||
if (!entry.countsTowardUnread || entry.isSelf) continue;
|
|
||||||
final target = entry.target;
|
|
||||||
if (target is rust.BridgeMessageTarget_Channel) {
|
|
||||||
// Channel target has no ID payload — it means "current channel".
|
|
||||||
// We can't distinguish per-channel without the channel ID in the target.
|
|
||||||
// For now, if there are any unread channel messages, mark the current voice channel.
|
|
||||||
if (_currentVoiceChannelId != null) {
|
|
||||||
ids.add(_currentVoiceChannelId!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The last chat target before the panel was closed. Used to restore the
|
|
||||||
/// previous conversation when the user reopens chat.
|
|
||||||
rust.BridgeMessageTarget? _lastDismissedTarget;
|
|
||||||
String _lastDismissedClientName = '';
|
|
||||||
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
|
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
|
||||||
const [],
|
const [],
|
||||||
);
|
);
|
||||||
@@ -592,8 +548,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
await rust.setHardMute(muted: false);
|
await rust.setHardMute(muted: false);
|
||||||
if (!mounted || !_hardMuteByPermission) return;
|
if (!mounted || !_hardMuteByPermission) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
_hardMute = false;
|
||||||
_hardMute = _hardMuteOwners.effective;
|
_hardMuteByPermission = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -636,39 +592,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showLocalNetworkDeniedSnackBar() {
|
|
||||||
if (!mounted) return;
|
|
||||||
final l10n = AppL10n.of(context);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
duration: const Duration(seconds: 8),
|
|
||||||
content: Text(
|
|
||||||
l10n.networkPermissionBody,
|
|
||||||
maxLines: 3,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
action: SnackBarAction(
|
|
||||||
label: l10n.networkPermissionOpenSettings,
|
|
||||||
onPressed: () {
|
|
||||||
// Fire-and-forget: don't block snackbar dismissal on the
|
|
||||||
// child process. `Process.run` may throw synchronously
|
|
||||||
// (e.g. exec ENOENT) or return a future that rejects
|
|
||||||
// (e.g. macOS denies fork); both paths are swallowed
|
|
||||||
// because the user can still open Settings manually.
|
|
||||||
unawaited(
|
|
||||||
Future<void>(() async {
|
|
||||||
await Process.run('open', [
|
|
||||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork',
|
|
||||||
]);
|
|
||||||
}).catchError((_) {}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _handleFocusedPttKey(KeyEvent event) {
|
bool _handleFocusedPttKey(KeyEvent event) {
|
||||||
final label = pttDisplayLabelForKey(event.logicalKey);
|
final label = pttDisplayLabelForKey(event.logicalKey);
|
||||||
final isBoundKey =
|
final isBoundKey =
|
||||||
@@ -768,10 +691,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
|
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
|
||||||
});
|
});
|
||||||
case rust.BridgeEvent_AudioStarted():
|
case rust.BridgeEvent_AudioStarted():
|
||||||
unawaited(iosAudioSessionController.activate());
|
|
||||||
_ensureStatsTimer();
|
_ensureStatsTimer();
|
||||||
case rust.BridgeEvent_AudioStopped():
|
case rust.BridgeEvent_AudioStopped():
|
||||||
unawaited(iosAudioSessionController.deactivate());
|
|
||||||
_statsTimer?.cancel();
|
_statsTimer?.cancel();
|
||||||
_statsTimer = null;
|
_statsTimer = null;
|
||||||
case rust.BridgeEvent_PttCapability(
|
case rust.BridgeEvent_PttCapability(
|
||||||
@@ -800,8 +721,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_voiceStateInitialized = true;
|
_voiceStateInitialized = true;
|
||||||
_inChannel = inChannel;
|
_inChannel = inChannel;
|
||||||
_transmitMode = transmitMode;
|
_transmitMode = transmitMode;
|
||||||
_hardMuteOwners = _hardMuteOwners.withBridgeManualMute(mute);
|
_hardMute = mute;
|
||||||
_hardMute = _hardMuteOwners.effective;
|
if (!mute) _hardMuteByPermission = false;
|
||||||
_releaseTailMs = releaseTailMs;
|
_releaseTailMs = releaseTailMs;
|
||||||
_currentVoiceChannelId = currentChannelId;
|
_currentVoiceChannelId = currentChannelId;
|
||||||
_pendingVoiceChannelId = pendingTargetChannelId;
|
_pendingVoiceChannelId = pendingTargetChannelId;
|
||||||
@@ -1114,36 +1035,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
String? password,
|
String? password,
|
||||||
}) async {
|
}) async {
|
||||||
final connectEpoch = ++_connectionEpoch;
|
final connectEpoch = ++_connectionEpoch;
|
||||||
|
|
||||||
// Trigger macOS Local Network permission prompt before connecting.
|
|
||||||
// On macOS 15+ the first network connection triggers the system
|
|
||||||
// dialog; triggering it via NWBrowser here ensures the prompt
|
|
||||||
// appears *before* the actual connect attempt so the user can grant
|
|
||||||
// permission and the connection succeeds in one flow.
|
|
||||||
final lnState = _macOSPermissions.localNetworkState.value;
|
|
||||||
if (lnState == MacOSLocalNetworkState.unknown ||
|
|
||||||
lnState == MacOSLocalNetworkState.notDetermined) {
|
|
||||||
await _macOSPermissions.triggerLocalNetworkPrompt();
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the prompt resolved to Denied (or was already Denied), confirm
|
|
||||||
// with a read-only NWConnection probe to the target host and show
|
|
||||||
// a snackbar directing the user to System Settings.
|
|
||||||
final resolvedState = _macOSPermissions.localNetworkState.value;
|
|
||||||
if (resolvedState == MacOSLocalNetworkState.denied) {
|
|
||||||
final targetHost = (host ?? _hostCtl.text).trim();
|
|
||||||
final accessState = await _macOSPermissions.checkLocalNetworkAccess(
|
|
||||||
host: targetHost,
|
|
||||||
port: 9987,
|
|
||||||
);
|
|
||||||
if (accessState == MacOSLocalNetworkState.denied) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() { _phase = ConnectionPhase.idle; });
|
|
||||||
_showLocalNetworkDeniedSnackBar();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_phase = ConnectionPhase.connecting;
|
_phase = ConnectionPhase.connecting;
|
||||||
_snapshot = null;
|
_snapshot = null;
|
||||||
@@ -1180,6 +1071,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
final errorStr = e.toString();
|
||||||
|
if (errorStr.contains('PermissionDenied') ||
|
||||||
|
errorStr.contains('Operation not permitted')) {
|
||||||
|
_showPermissionDeniedDialog(errorStr);
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_phase = ConnectionPhase.idle;
|
_phase = ConnectionPhase.idle;
|
||||||
});
|
});
|
||||||
@@ -1204,6 +1100,59 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showPermissionDeniedDialog(String rawError) {
|
||||||
|
final l10n = AppL10n.of(context);
|
||||||
|
final isNetwork = rawError.contains('9987') || rawError.contains('connect');
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied,
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
isNetwork
|
||||||
|
? l10n.networkPermissionBody
|
||||||
|
: l10n.microphonePermissionBody,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (Platform.isIOS && !isNetwork)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
unawaited(_openIosAppSettings());
|
||||||
|
},
|
||||||
|
child: Text(l10n.networkPermissionOpenSettings),
|
||||||
|
),
|
||||||
|
if (Platform.isMacOS)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
try {
|
||||||
|
Process.run('open', [
|
||||||
|
'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork',
|
||||||
|
]);
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
child: Text(l10n.networkPermissionOpenSettings),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openIosAppSettings() async {
|
||||||
|
try {
|
||||||
|
await _iosPlatformChannel.invokeMethod<bool>('openAppSettings');
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort affordance only; if iOS refuses the URL, the
|
||||||
|
// dialog still explained the missing microphone permission.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _setPtt(bool active, {bool reportError = true}) async {
|
Future<void> _setPtt(bool active, {bool reportError = true}) async {
|
||||||
if (active) {
|
if (active) {
|
||||||
try {
|
try {
|
||||||
@@ -1273,8 +1222,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
await rust.setHardMute(muted: true);
|
await rust.setHardMute(muted: true);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_hardMuteOwners = _hardMuteOwners.copyWith(permission: true);
|
_hardMute = true;
|
||||||
_hardMute = _hardMuteOwners.effective;
|
_hardMuteByPermission = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -1289,8 +1238,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
await rust.setHardMute(muted: false);
|
await rust.setHardMute(muted: false);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
_hardMute = false;
|
||||||
_hardMute = _hardMuteOwners.effective;
|
_hardMuteByPermission = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1358,14 +1307,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
final next = !_hardMute;
|
final next = !_hardMute;
|
||||||
final previousInputMuted = _inputMuted;
|
final previousInputMuted = _inputMuted;
|
||||||
final previousHardMute = _hardMute;
|
final previousHardMute = _hardMute;
|
||||||
final previousHardMuteOwners = _hardMuteOwners;
|
final previousPermissionMute = _hardMuteByPermission;
|
||||||
setState(() {
|
setState(() {
|
||||||
_inputMuted = next;
|
_inputMuted = next;
|
||||||
_hardMuteOwners = _hardMuteOwners.copyWith(
|
_hardMute = next;
|
||||||
manual: next,
|
if (next) {
|
||||||
permission: next ? false : null,
|
_hardMuteByPermission = false;
|
||||||
);
|
}
|
||||||
_hardMute = _hardMuteOwners.effective;
|
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
// Hard-mute is two coordinated effects:
|
// Hard-mute is two coordinated effects:
|
||||||
@@ -1384,7 +1332,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_inputMuted = previousInputMuted;
|
_inputMuted = previousInputMuted;
|
||||||
_hardMute = previousHardMute;
|
_hardMute = previousHardMute;
|
||||||
_hardMuteOwners = previousHardMuteOwners;
|
_hardMuteByPermission = previousPermissionMute;
|
||||||
});
|
});
|
||||||
_showUiError('hard mute', e);
|
_showUiError('hard mute', e);
|
||||||
}
|
}
|
||||||
@@ -1654,7 +1602,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_inputMuted = false;
|
_inputMuted = false;
|
||||||
_outputMuted = false;
|
_outputMuted = false;
|
||||||
_hardMute = false;
|
_hardMute = false;
|
||||||
_hardMuteOwners = const HardMuteOwners();
|
_hardMuteByPermission = false;
|
||||||
|
_hardMuteByTalkPower = false;
|
||||||
_inChannel = false;
|
_inChannel = false;
|
||||||
_currentVoiceChannelId = null;
|
_currentVoiceChannelId = null;
|
||||||
_pendingVoiceChannelId = null;
|
_pendingVoiceChannelId = null;
|
||||||
@@ -1664,88 +1613,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_reconnectAttempt = null;
|
_reconnectAttempt = null;
|
||||||
_reconnectDelay = null;
|
_reconnectDelay = null;
|
||||||
_chatMessages.clear();
|
_chatMessages.clear();
|
||||||
_chatDrafts.clear();
|
|
||||||
_chatUnread = 0;
|
_chatUnread = 0;
|
||||||
_chatOpen = false;
|
_chatOpen = false;
|
||||||
_inlineChatTarget = null;
|
|
||||||
_inlineChatClientName = '';
|
|
||||||
_inlineChatCollapseNoticeShown = false;
|
|
||||||
_lastDismissedTarget = null;
|
|
||||||
_lastDismissedClientName = '';
|
|
||||||
_notifyChatFeedChanged();
|
_notifyChatFeedChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts a [rust.BridgeMessageTarget] to a stable string key for draft storage.
|
|
||||||
String _draftKeyForTarget(rust.BridgeMessageTarget target) {
|
|
||||||
return switch (target) {
|
|
||||||
rust.BridgeMessageTarget_Server() => 'server',
|
|
||||||
rust.BridgeMessageTarget_Channel() => 'channel',
|
|
||||||
rust.BridgeMessageTarget_Client(:final field0) => 'client:$field0',
|
|
||||||
rust.BridgeMessageTarget_Poke(:final field0) => 'poke:$field0',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the current draft text for the inline chat target, or null
|
|
||||||
/// if no draft has been stored.
|
|
||||||
///
|
|
||||||
/// Draft persistence relies on [ChatDetailView]'s own lifecycle:
|
|
||||||
/// `didUpdateWidget` flushes the outgoing target's draft via
|
|
||||||
/// `onDraftChanged` when the parent rebuilds with a new target, and
|
|
||||||
/// `dispose` flushes the final draft when the panel is torn down
|
|
||||||
/// (e.g. on [_closeInlineChat]). Both paths feed
|
|
||||||
/// [_chatDrafts] without requiring an explicit save call from this
|
|
||||||
/// class.
|
|
||||||
String? _currentDraftFor(rust.BridgeMessageTarget target) =>
|
|
||||||
_chatDrafts[_draftKeyForTarget(target)];
|
|
||||||
|
|
||||||
Future<void> _onOpenChat({
|
Future<void> _onOpenChat({
|
||||||
rust.BridgeMessageTarget? target,
|
rust.BridgeMessageTarget? target,
|
||||||
String clientName = '',
|
String clientName = '',
|
||||||
}) async {
|
}) async {
|
||||||
final initialSnapshot = _snapshot!;
|
final initialSnapshot = _snapshot!;
|
||||||
|
|
||||||
// Resolve the new target.
|
|
||||||
// If no target passed and panel is already open, switch to current voice channel.
|
|
||||||
// If no target passed and panel is closed, resolve from history or default.
|
|
||||||
rust.BridgeMessageTarget newTarget;
|
|
||||||
if (target != null) {
|
|
||||||
newTarget = target;
|
|
||||||
} else if (_chatOpen && _currentVoiceChannelId != null) {
|
|
||||||
newTarget = const rust.BridgeMessageTarget.channel();
|
|
||||||
} else if (_inlineChatTarget != null) {
|
|
||||||
newTarget = _inlineChatTarget!;
|
|
||||||
} else {
|
|
||||||
newTarget =
|
|
||||||
resolveInitialChatTarget(
|
|
||||||
messages: _chatMessages,
|
|
||||||
currentVoiceChannelId: _currentVoiceChannelId,
|
|
||||||
) ??
|
|
||||||
const rust.BridgeMessageTarget.server();
|
|
||||||
}
|
|
||||||
|
|
||||||
final newClientName = clientName.isNotEmpty
|
|
||||||
? clientName
|
|
||||||
: (newTarget == _inlineChatTarget)
|
|
||||||
? _inlineChatClientName
|
|
||||||
: (newTarget == _lastDismissedTarget)
|
|
||||||
? _lastDismissedClientName
|
|
||||||
: '';
|
|
||||||
|
|
||||||
final isExpanded =
|
|
||||||
layoutClassFromWidth(MediaQuery.sizeOf(context).width) ==
|
|
||||||
LayoutClass.expanded;
|
|
||||||
if (isExpanded) {
|
|
||||||
// Outgoing draft is preserved by ChatDetailView.didUpdateWidget,
|
|
||||||
// which fires onDraftChanged with the old target's text on rebuild.
|
|
||||||
setState(() {
|
|
||||||
_chatUnread = 0;
|
|
||||||
_chatOpen = true;
|
|
||||||
_inlineChatTarget = newTarget;
|
|
||||||
_inlineChatClientName = newClientName;
|
|
||||||
_inlineChatCollapseNoticeShown = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_chatUnread = 0;
|
_chatUnread = 0;
|
||||||
_chatOpen = true;
|
_chatOpen = true;
|
||||||
@@ -1758,8 +1635,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
messagesSource: () => _chatMessages,
|
messagesSource: () => _chatMessages,
|
||||||
snapshotSource: () => _snapshot ?? initialSnapshot,
|
snapshotSource: () => _snapshot ?? initialSnapshot,
|
||||||
refreshListenable: _chatFeedRevision,
|
refreshListenable: _chatFeedRevision,
|
||||||
initialTarget: newTarget,
|
initialTarget:
|
||||||
initialClientName: newClientName,
|
target ??
|
||||||
|
resolveInitialChatTarget(
|
||||||
|
messages: _chatMessages,
|
||||||
|
currentVoiceChannelId: _currentVoiceChannelId,
|
||||||
|
),
|
||||||
|
initialClientName: clientName,
|
||||||
onTs3ServerLink: _onTs3ServerLink,
|
onTs3ServerLink: _onTs3ServerLink,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1767,50 +1649,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
if (mounted) setState(() => _chatOpen = false);
|
if (mounted) setState(() => _chatOpen = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _closeInlineChat() {
|
|
||||||
// Final draft for the dismissed target is preserved by
|
|
||||||
// ChatDetailView.dispose, which fires onDraftChanged when the panel
|
|
||||||
// is removed from the tree on the next rebuild.
|
|
||||||
setState(() {
|
|
||||||
_lastDismissedTarget = _inlineChatTarget;
|
|
||||||
_lastDismissedClientName = _inlineChatClientName;
|
|
||||||
_chatOpen = false;
|
|
||||||
// Do NOT null _inlineChatTarget — remember it for reopen.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleInlineChatViewport(LayoutClass layoutClass) {
|
|
||||||
if (_inlineChatTarget == null) return;
|
|
||||||
if (layoutClass == LayoutClass.expanded) {
|
|
||||||
_inlineChatCollapseNoticeShown = false;
|
|
||||||
if (!_chatOpen) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted && _inlineChatTarget != null) {
|
|
||||||
setState(() => _chatOpen = true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_chatOpen) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted && _inlineChatTarget != null) {
|
|
||||||
setState(() => _chatOpen = false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (_inlineChatCollapseNoticeShown) return;
|
|
||||||
_inlineChatCollapseNoticeShown = true;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!mounted || _inlineChatTarget == null) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(AppL10n.of(context).chatPanelCollapsedHint),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onOpenClientInfo(rust.BridgeClient client) async {
|
Future<void> _onOpenClientInfo(rust.BridgeClient client) async {
|
||||||
await showModalBottomSheet<void>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -1919,7 +1757,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
const side = 16.0;
|
const side = 16.0;
|
||||||
var bottom = 16.0;
|
var bottom = 16.0;
|
||||||
final wideConnectedLayout =
|
final wideConnectedLayout =
|
||||||
MediaQuery.sizeOf(context).width >= ChanoraBreakpoints.medium &&
|
MediaQuery.sizeOf(context).width >= _wideBreakpoint &&
|
||||||
_serverReachable &&
|
_serverReachable &&
|
||||||
_snapshot != null;
|
_snapshot != null;
|
||||||
|
|
||||||
@@ -2085,8 +1923,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
|
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
|
||||||
final talkPowerEpoch = _connectionEpoch;
|
final talkPowerEpoch = _connectionEpoch;
|
||||||
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: true);
|
_hardMuteByTalkPower = true;
|
||||||
_hardMute = _hardMuteOwners.effective;
|
_hardMute = true;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted ||
|
if (!mounted ||
|
||||||
_connectionEpoch != talkPowerEpoch ||
|
_connectionEpoch != talkPowerEpoch ||
|
||||||
@@ -2099,14 +1937,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
});
|
});
|
||||||
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
|
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
|
||||||
final talkPowerEpoch = _connectionEpoch;
|
final talkPowerEpoch = _connectionEpoch;
|
||||||
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: false);
|
_hardMuteByTalkPower = false;
|
||||||
_hardMute = _hardMuteOwners.effective;
|
if (!_hardMuteByPermission) {
|
||||||
if (!_hardMuteOwners.effective) {
|
_hardMute = false;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted ||
|
if (!mounted ||
|
||||||
_connectionEpoch != talkPowerEpoch ||
|
_connectionEpoch != talkPowerEpoch ||
|
||||||
!_serverReachable ||
|
!_serverReachable ||
|
||||||
_hardMuteOwners.effective) {
|
_hardMuteByTalkPower ||
|
||||||
|
_hardMuteByPermission) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unawaited(rust.setHardMute(muted: false));
|
unawaited(rust.setHardMute(muted: false));
|
||||||
@@ -2406,8 +2245,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
)
|
)
|
||||||
: headerTitle;
|
: headerTitle;
|
||||||
final compactIdleChrome =
|
final compactIdleChrome =
|
||||||
!_serverReachable &&
|
!_serverReachable && MediaQuery.sizeOf(context).width < _wideBreakpoint;
|
||||||
MediaQuery.sizeOf(context).width < ChanoraBreakpoints.medium;
|
|
||||||
final compactIdleHeader = Row(
|
final compactIdleHeader = Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -2439,10 +2277,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
final bodyContent = LayoutBuilder(
|
final bodyContent = LayoutBuilder(
|
||||||
builder: (ctx, bodyConstraints) {
|
builder: (ctx, bodyConstraints) {
|
||||||
final layoutClass = layoutClassFromWidth(bodyConstraints.maxWidth);
|
|
||||||
_handleInlineChatViewport(layoutClass);
|
|
||||||
final isWideSnapshot =
|
final isWideSnapshot =
|
||||||
bodyConstraints.maxWidth >= ChanoraBreakpoints.medium &&
|
bodyConstraints.maxWidth >= _wideBreakpoint &&
|
||||||
_serverReachable &&
|
_serverReachable &&
|
||||||
_snapshot != null;
|
_snapshot != null;
|
||||||
final banner = Container(
|
final banner = Container(
|
||||||
@@ -2456,11 +2292,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
|
style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return ViewportInfo(
|
return Column(
|
||||||
layoutClass: layoutClass,
|
|
||||||
width: bodyConstraints.maxWidth,
|
|
||||||
height: bodyConstraints.maxHeight,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
|
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
|
||||||
@@ -2484,8 +2316,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color:
|
||||||
connTokens.background ??
|
connTokens.background ?? theme.colorScheme.errorContainer,
|
||||||
theme.colorScheme.errorContainer,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -2612,7 +2443,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
localOutputMuted: _outputMuted,
|
localOutputMuted: _outputMuted,
|
||||||
hasJoinPending: _pendingVoiceChannelId != null,
|
hasJoinPending: _pendingVoiceChannelId != null,
|
||||||
canJoinVoiceChannel: _canJoinVoiceChannel,
|
canJoinVoiceChannel: _canJoinVoiceChannel,
|
||||||
unreadChannelIds: _unreadChannelIds,
|
|
||||||
onJoinChannel: (ch) => _onJoinChannel(ch),
|
onJoinChannel: (ch) => _onJoinChannel(ch),
|
||||||
onJoinChannelWithPassword: (ch) =>
|
onJoinChannelWithPassword: (ch) =>
|
||||||
_onJoinChannel(ch, askForPassword: true),
|
_onJoinChannel(ch, askForPassword: true),
|
||||||
@@ -2631,22 +2461,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
clientName: client.name,
|
clientName: client.name,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onOpenChannelChat: (channel) => unawaited(
|
|
||||||
_onOpenChat(
|
|
||||||
target: const rust.BridgeMessageTarget.channel(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTs3ServerLink: _onTs3ServerLink,
|
onTs3ServerLink: _onTs3ServerLink,
|
||||||
);
|
);
|
||||||
final ownClientState = _ownClientState;
|
final ownClientState = _ownClientState;
|
||||||
final layoutInfo = ViewportInfo.of(ctx);
|
const voiceBarWidthWide = 320.0;
|
||||||
if (layoutInfo.isWide) {
|
if (constraints.maxWidth >= _wideBreakpoint) {
|
||||||
final inlineChatTarget = _inlineChatTarget;
|
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: ChanoraBreakpoints.voicePanelWidth,
|
width: voiceBarWidthWide,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -2657,31 +2481,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChanoraBreakpoints.panelGap),
|
const SizedBox(width: 12),
|
||||||
Expanded(child: snapshotView),
|
Expanded(child: snapshotView),
|
||||||
if (layoutInfo.isExpanded &&
|
|
||||||
inlineChatTarget != null) ...[
|
|
||||||
const SizedBox(
|
|
||||||
width: ChanoraBreakpoints.panelGap,
|
|
||||||
),
|
|
||||||
ChatPanel(
|
|
||||||
messages: _chatMessages,
|
|
||||||
snapshot: _snapshot!,
|
|
||||||
target: inlineChatTarget,
|
|
||||||
clientName: _inlineChatClientName,
|
|
||||||
restoredDraft: _currentDraftFor(
|
|
||||||
inlineChatTarget,
|
|
||||||
),
|
|
||||||
onDraftChanged: (text) {
|
|
||||||
final draftKey = _draftKeyForTarget(
|
|
||||||
inlineChatTarget,
|
|
||||||
);
|
|
||||||
_chatDrafts[draftKey] = text;
|
|
||||||
},
|
|
||||||
onTs3ServerLink: _onTs3ServerLink,
|
|
||||||
onClose: _closeInlineChat,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2722,7 +2523,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -442,37 +440,6 @@ class MacOSPermissionsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe whether Local Network access is currently denied for [host]:[port]
|
|
||||||
/// by creating a short-lived NWConnection and checking
|
|
||||||
/// `unsatisfiedReason == .localNetworkDenied`.
|
|
||||||
///
|
|
||||||
/// This does NOT trigger a new system prompt — it is a read-only check.
|
|
||||||
/// Returns [MacOSLocalNetworkState.unsupported] on non-macOS platforms.
|
|
||||||
Future<MacOSLocalNetworkState> checkLocalNetworkAccess({
|
|
||||||
required String host,
|
|
||||||
required int port,
|
|
||||||
}) async {
|
|
||||||
final ch = _channel;
|
|
||||||
if (ch == null) return MacOSLocalNetworkState.unsupported;
|
|
||||||
try {
|
|
||||||
final raw = await ch.invokeMethod<String>(
|
|
||||||
methodCheckLocalNetworkAccess,
|
|
||||||
<String, dynamic>{'host': host, 'port': port},
|
|
||||||
);
|
|
||||||
final state = _parseLocalNetworkState(raw);
|
|
||||||
_localNetworkState.value = state;
|
|
||||||
return state;
|
|
||||||
} catch (e, st) {
|
|
||||||
developer.log(
|
|
||||||
'checkLocalNetworkAccess failed',
|
|
||||||
name: 'MacOSPermissionsService',
|
|
||||||
error: e,
|
|
||||||
stackTrace: st,
|
|
||||||
);
|
|
||||||
return _localNetworkState.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Outbound: Notifications ----------------------------------------------
|
// -- Outbound: Notifications ----------------------------------------------
|
||||||
|
|
||||||
Future<MacOSPermissionState> _checkNotifications() async {
|
Future<MacOSPermissionState> _checkNotifications() async {
|
||||||
|
|||||||
@@ -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,19 +1216,12 @@ 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(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: widget.messageMaxWidth ?? double.infinity,
|
|
||||||
),
|
|
||||||
child: _MessageBubble(
|
|
||||||
entry: msgs[i],
|
entry: msgs[i],
|
||||||
onTs3ServerLink: widget.onTs3ServerLink,
|
onTs3ServerLink: widget.onTs3ServerLink,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_blockedReason != null)
|
if (_blockedReason != null)
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|||||||
@@ -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,12 +240,7 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _ChannelContextMenu(
|
return InkWell(
|
||||||
channel: channel,
|
|
||||||
onChat: widget.onOpenChannelChat != null
|
|
||||||
? () => widget.onOpenChannelChat!(channel)
|
|
||||||
: null,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(minHeight: 40),
|
constraints: const BoxConstraints(minHeight: 40),
|
||||||
@@ -284,17 +271,6 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
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) ...[
|
if (channel.hasPassword) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Icon(
|
Icon(
|
||||||
@@ -305,7 +281,6 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1107,60 +1082,3 @@ class _ClientVolumeSheetState extends State<_ClientVolumeSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Context menu for channel tiles. Shows a "Chat" option on right-click or
|
|
||||||
/// long-press. Primary tap passes through to the child for voice join.
|
|
||||||
class _ChannelContextMenu extends StatelessWidget {
|
|
||||||
const _ChannelContextMenu({
|
|
||||||
required this.channel,
|
|
||||||
this.onChat,
|
|
||||||
required this.child,
|
|
||||||
});
|
|
||||||
|
|
||||||
final rust.BridgeChannel channel;
|
|
||||||
final VoidCallback? onChat;
|
|
||||||
final Widget child;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (onChat == null) return child;
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onSecondaryTapDown: (details) =>
|
|
||||||
_show(context, details.globalPosition),
|
|
||||||
onLongPressStart: (details) =>
|
|
||||||
_show(context, details.globalPosition),
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _show(BuildContext context, Offset globalPosition) {
|
|
||||||
final overlay =
|
|
||||||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
|
||||||
final position = RelativeRect.fromLTRB(
|
|
||||||
globalPosition.dx,
|
|
||||||
globalPosition.dy,
|
|
||||||
overlay.size.width - globalPosition.dx,
|
|
||||||
overlay.size.height - globalPosition.dy,
|
|
||||||
);
|
|
||||||
showMenu<String>(
|
|
||||||
context: context,
|
|
||||||
position: position,
|
|
||||||
items: [
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'chat',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.chat_bubble_outline, size: 18),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(AppL10n.of(context).chatAction),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).then((value) {
|
|
||||||
if (value == 'chat') onChat?.call();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -318,15 +318,6 @@ class _VoicePttButtonState extends State<VoicePttButton> {
|
|||||||
playVoicePttHaptic(held);
|
playVoicePttHaptic(held);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
if (_pressed) {
|
|
||||||
_pressed = false;
|
|
||||||
widget.onHeldChanged(false);
|
|
||||||
}
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral
|
:path: Flutter/ephemeral
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
chanora_bridge: 9d1469952801a1caa3bb56d5d3bce91df8dca4ad
|
chanora_bridge: 4105993843b5421ee4ce72220a74c63f6fd99103
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
|
|
||||||
PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb
|
PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb
|
||||||
|
|||||||
@@ -248,7 +248,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 */,
|
||||||
@@ -442,21 +441,6 @@
|
|||||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
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 */
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -63,18 +63,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 +199,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 +214,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 +247,6 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe whether Local Network access is currently denied for a specific
|
|
||||||
/// host:port by creating a short-lived NWConnection and checking
|
|
||||||
/// `unsatisfiedReason == .localNetworkDenied`. This does NOT trigger a
|
|
||||||
/// new system prompt — it is a read-only check.
|
|
||||||
private func checkLocalNetworkAccess(
|
|
||||||
host: String, port: Int, result: @escaping FlutterResult
|
|
||||||
) {
|
|
||||||
if #available(macOS 15.0, *) {
|
|
||||||
checkLocalNetworkAccessImpl(host: host, port: port, result: result)
|
|
||||||
} else {
|
|
||||||
result("Unsupported")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 15.0, *)
|
|
||||||
private func checkLocalNetworkAccessImpl(
|
|
||||||
host: String, port: Int, result: @escaping FlutterResult
|
|
||||||
) {
|
|
||||||
guard let endpointPort = NWEndpoint.Port(rawValue: UInt16(port)) else {
|
|
||||||
result("NotDetermined")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let endpointHost = NWEndpoint.Host(host)
|
|
||||||
let connection = NWConnection(
|
|
||||||
host: endpointHost, port: endpointPort, using: .tcp)
|
|
||||||
let queue = DispatchQueue(
|
|
||||||
label: "app.chanora.macos_permissions.local_network_check")
|
|
||||||
|
|
||||||
var didComplete = false
|
|
||||||
|
|
||||||
func finish(_ state: String) {
|
|
||||||
guard !didComplete else { return }
|
|
||||||
didComplete = true
|
|
||||||
connection.cancel()
|
|
||||||
result(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.stateUpdateHandler = { state in
|
|
||||||
switch state {
|
|
||||||
case .waiting, .failed:
|
|
||||||
if connection.currentPath?.unsatisfiedReason
|
|
||||||
== .localNetworkDenied {
|
|
||||||
finish("Denied")
|
|
||||||
} else {
|
|
||||||
finish("NotDetermined")
|
|
||||||
}
|
|
||||||
case .ready:
|
|
||||||
finish("Granted")
|
|
||||||
case .cancelled:
|
|
||||||
finish("NotDetermined")
|
|
||||||
case .setup, .preparing:
|
|
||||||
break
|
|
||||||
@unknown default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.start(queue: queue)
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
||||||
if !didComplete {
|
|
||||||
if connection.currentPath?.unsatisfiedReason
|
|
||||||
== .localNetworkDenied {
|
|
||||||
finish("Denied")
|
|
||||||
} else {
|
|
||||||
finish("NotDetermined")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Notifications
|
// Notifications
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -399,8 +304,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,42 +141,32 @@ 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"
|
||||||
@@ -225,22 +197,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"
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -580,7 +550,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 +577,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 +611,6 @@ void main() {
|
|||||||
localOutputMuted: false,
|
localOutputMuted: false,
|
||||||
hasJoinPending: false,
|
hasJoinPending: false,
|
||||||
canJoinVoiceChannel: true,
|
canJoinVoiceChannel: true,
|
||||||
unreadChannelIds: const {},
|
|
||||||
onJoinChannel: (_) {},
|
onJoinChannel: (_) {},
|
||||||
onJoinChannelWithPassword: (_) {},
|
onJoinChannelWithPassword: (_) {},
|
||||||
),
|
),
|
||||||
@@ -755,7 +665,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,
|
|
||||||
}
|
|
||||||
+341
-145
@@ -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,291 @@ 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 {
|
||||||
|
/// New audio output route.
|
||||||
|
route: AudioRoute,
|
||||||
|
},
|
||||||
|
/// A client moved to a different channel.
|
||||||
|
ClientMoved {
|
||||||
|
/// Unique client identifier.
|
||||||
|
client_id: u64,
|
||||||
|
/// Destination channel.
|
||||||
|
new_channel_id: u64,
|
||||||
|
},
|
||||||
|
/// A new client connected.
|
||||||
|
ClientJoined {
|
||||||
|
/// Unique client identifier.
|
||||||
|
client_id: u64,
|
||||||
|
/// Channel the client joined.
|
||||||
|
channel_id: u64,
|
||||||
|
/// Display nickname.
|
||||||
|
name: String,
|
||||||
|
/// Microphone muted state.
|
||||||
|
input_muted: bool,
|
||||||
|
/// Speaker muted state.
|
||||||
|
output_muted: bool,
|
||||||
|
/// Whether this is a server query (bot) client.
|
||||||
|
is_server_query: bool,
|
||||||
|
/// Client's talk power value.
|
||||||
|
talk_power: i32,
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
|
talk_power_granted: bool,
|
||||||
|
},
|
||||||
|
/// A client disconnected.
|
||||||
|
ClientLeft {
|
||||||
|
/// Unique client identifier.
|
||||||
|
client_id: u64,
|
||||||
|
/// Display nickname at time of disconnect.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Client properties changed.
|
||||||
|
ClientUpdated {
|
||||||
|
/// Unique client identifier.
|
||||||
|
client_id: u64,
|
||||||
|
/// Microphone muted state.
|
||||||
|
input_muted: bool,
|
||||||
|
/// Speaker muted state.
|
||||||
|
output_muted: bool,
|
||||||
|
/// Whether this is a server query (bot) client.
|
||||||
|
is_server_query: bool,
|
||||||
|
/// Client's talk power value.
|
||||||
|
talk_power: i32,
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
|
talk_power_granted: bool,
|
||||||
|
},
|
||||||
|
/// A new channel appeared.
|
||||||
|
ChannelAdded {
|
||||||
|
/// Unique channel identifier.
|
||||||
|
id: u64,
|
||||||
|
/// Parent channel ID.
|
||||||
|
parent: u64,
|
||||||
|
/// Channel name.
|
||||||
|
name: String,
|
||||||
|
/// Predecessor channel ID within the same parent (TeamSpeak
|
||||||
|
/// linked-list ordering hint). Zero means first child.
|
||||||
|
order: i64,
|
||||||
|
/// Whether the channel requires a password.
|
||||||
|
has_password: bool,
|
||||||
|
/// Talk power required to speak, or `None` when unrestricted.
|
||||||
|
needed_talk_power: Option<i32>,
|
||||||
|
},
|
||||||
|
/// A channel was deleted.
|
||||||
|
ChannelRemoved {
|
||||||
|
/// Channel identifier.
|
||||||
|
id: u64,
|
||||||
|
},
|
||||||
|
/// Channel properties changed.
|
||||||
|
ChannelUpdated {
|
||||||
|
/// Unique channel identifier.
|
||||||
|
id: u64,
|
||||||
|
/// Channel name.
|
||||||
|
name: String,
|
||||||
|
/// Whether the channel requires a password.
|
||||||
|
has_password: bool,
|
||||||
|
/// Talk power required to speak, or `None` when unrestricted.
|
||||||
|
needed_talk_power: Option<i32>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge-safe mirror of channel-join projection sync state.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum VoiceJoinSyncState {
|
||||||
|
/// Reducer is ready to accept channel actions.
|
||||||
|
Ready,
|
||||||
|
/// Reducer is synchronizing against an initial snapshot.
|
||||||
|
SynchronizingInitialSnapshot,
|
||||||
|
/// Reducer is synchronizing after reconnect.
|
||||||
|
SynchronizingReconnect,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge-safe mirror of stable channel-join error codes.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum VoiceJoinErrorCode {
|
||||||
|
/// Duplicate same-target join intent was coalesced.
|
||||||
|
DuplicateSameTargetCoalesced,
|
||||||
|
/// A different target was requested while one is already pending.
|
||||||
|
JoinAlreadyPendingDifferentTarget,
|
||||||
|
/// Join denied by server policy/permission.
|
||||||
|
JoinDenied,
|
||||||
|
/// Join failed due to protocol-level error.
|
||||||
|
JoinProtocolFailure,
|
||||||
|
/// Join failed due to transport/network error.
|
||||||
|
JoinNetworkFailure,
|
||||||
|
/// Join timed out awaiting confirmation.
|
||||||
|
JoinTimeout,
|
||||||
|
/// Pending join was superseded by user leave.
|
||||||
|
JoinSupersededByLeave,
|
||||||
|
/// Stale join outcome was ignored.
|
||||||
|
JoinStaleOutcomeIgnored,
|
||||||
|
/// Authoritative membership reconciled to different channel.
|
||||||
|
JoinReconciledDifferentChannel,
|
||||||
|
/// Join command was rejected before send acceptance.
|
||||||
|
JoinCommandRejectedBeforeSend,
|
||||||
|
/// Join intent rejected while reducer synchronizing.
|
||||||
|
JoinCannotStartWhileSynchronizing,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coarse OS-reported network state. Populated by the Flutter side
|
||||||
|
/// via `connectivity_plus`; on platforms where no signal is wired
|
||||||
|
/// we stay at `Unknown` forever and the supervisor falls back to
|
||||||
|
/// pure watchdog/backoff behaviour.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum NetworkState {
|
||||||
|
/// No signal seen yet — treat as ambiguous; don't change behaviour.
|
||||||
|
Unknown,
|
||||||
|
/// OS reports at least one network with internet capability.
|
||||||
|
Online,
|
||||||
|
/// OS reports no networks available.
|
||||||
|
Offline,
|
||||||
|
}
|
||||||
|
|
||||||
/// Channel capacity for the broadcast events. Generous because
|
/// 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 +477,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())
|
||||||
@@ -1569,7 +1878,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 +1895,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 +1934,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 +1989,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);
|
||||||
}
|
}
|
||||||
@@ -2389,54 +2633,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]"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,7 @@ crossbeam = { version = "0.8", default-features = false, features = ["alloc", "c
|
|||||||
# Desktop audio I/O for Windows capture/playback and Linux capture.
|
# 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 +58,7 @@ ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dyn
|
|||||||
# JNI bindings to flip Android's AudioManager into MODE_IN_COMMUNICATION
|
# 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
|
||||||
@@ -84,7 +84,7 @@ bytemuck = { version = "1", features = ["derive"] }
|
|||||||
# + 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 +104,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 +147,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 +166,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,16 +420,7 @@ 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,
|
|
||||||
vad_backend,
|
|
||||||
self.silero_vad_worker.is_some(),
|
|
||||||
) {
|
|
||||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
|
||||||
let worker = self
|
|
||||||
.silero_vad_worker
|
|
||||||
.as_ref()
|
|
||||||
.expect("policy checked worker");
|
|
||||||
let enqueued = worker.try_send(capture_seq, &frame);
|
let enqueued = worker.try_send(capture_seq, &frame);
|
||||||
if !worker.is_stale(capture_seq) {
|
if !worker.is_stale(capture_seq) {
|
||||||
let p = worker.latest_probability();
|
let p = worker.latest_probability();
|
||||||
@@ -405,19 +441,10 @@ impl AndroidCaptureState {
|
|||||||
&frame,
|
&frame,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
|
||||||
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(&mut self.vad_detector, &frame)
|
||||||
&mut self.vad_detector,
|
|
||||||
&frame,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
|
||||||
probability: 1.0,
|
|
||||||
speech: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
} 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_consumer: crate::audio_event_queue::AudioEventConsumer,
|
||||||
output_gain: Arc<AtomicU32>,
|
output_gain: Arc<AtomicU32>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
event_tx: BackendEventTx,
|
event_tx: BackendEventTx,
|
||||||
@@ -516,39 +542,47 @@ 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] =
|
||||||
|
bytemuck::cast_slice_mut::<(f32, f32), f32>(frames);
|
||||||
|
for s in buf.iter_mut() {
|
||||||
|
*s = 0.0;
|
||||||
|
}
|
||||||
|
for cmd in self.event_consumer.drain_controls() {
|
||||||
|
match cmd {
|
||||||
|
AudioCommand::SetVolume(id, vol) => {
|
||||||
|
if let Some(q) = self.handler.get_mut_queues().get_mut(&id) {
|
||||||
|
q.volume = vol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioCommand::RemoveClient(id) => {
|
||||||
|
self.handler.get_mut_queues().remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pkt in self.event_consumer.drain_packets(50) {
|
||||||
|
if let Err(e) = self.handler.handle_packet(pkt.client_id, pkt.data) {
|
||||||
|
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = self.handler.fill_buffer(buf);
|
||||||
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
||||||
let muted = self.output_muted.load(Ordering::Relaxed);
|
let muted = self.output_muted.load(Ordering::Relaxed);
|
||||||
if muted {
|
if muted {
|
||||||
for 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 +614,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 +635,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 +706,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 +722,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 +789,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_consumer: AudioEventQueue::consumer(&event_queue),
|
||||||
output_gain: params.output_gain.clone(),
|
output_gain: params.output_gain.clone(),
|
||||||
output_muted: params.output_muted.clone(),
|
output_muted: params.output_muted.clone(),
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
@@ -789,7 +813,8 @@ impl AndroidVoiceUnit {
|
|||||||
Self::open_output_fallback(
|
Self::open_output_fallback(
|
||||||
cfg,
|
cfg,
|
||||||
&event_tx,
|
&event_tx,
|
||||||
render_ring.consumer(),
|
AudioHandler::new(),
|
||||||
|
AudioEventQueue::consumer(&event_queue),
|
||||||
params.output_gain.clone(),
|
params.output_gain.clone(),
|
||||||
params.output_muted.clone(),
|
params.output_muted.clone(),
|
||||||
audio_processing_stats.clone(),
|
audio_processing_stats.clone(),
|
||||||
@@ -797,11 +822,6 @@ impl AndroidVoiceUnit {
|
|||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let render_producer_shutdown = Self::spawn_render_producer(
|
|
||||||
params.handler,
|
|
||||||
AudioEventQueue::consumer(&event_queue),
|
|
||||||
render_ring.producer(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let output_frames_per_burst = output_stream.get_frames_per_burst();
|
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 +978,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 +995,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 +1031,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 +1066,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_consumer: crate::audio_event_queue::AudioEventConsumer,
|
||||||
output_gain: Arc<AtomicU32>,
|
output_gain: Arc<AtomicU32>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
render_reference: Arc<RenderReferenceBuffer>,
|
render_reference: Arc<RenderReferenceBuffer>,
|
||||||
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
||||||
let cb = OutputCallback {
|
let cb = OutputCallback {
|
||||||
pcm_consumer,
|
handler,
|
||||||
|
event_consumer,
|
||||||
output_gain,
|
output_gain,
|
||||||
output_muted,
|
output_muted,
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
@@ -1081,50 +1100,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 +1151,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 +1208,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,11 +1287,33 @@ 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 ctx = ndk_context::android_context();
|
||||||
|
if ctx.vm().is_null() {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
"android: ndk_context vm null; cannot bind hardware effects (software fallback engages)"
|
||||||
|
);
|
||||||
|
return HardwareEffectHandles::default();
|
||||||
|
}
|
||||||
|
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(target: "chanora_audio", error = %e, "android: JavaVM::from_raw failed; effects not bound");
|
||||||
|
return HardwareEffectHandles::default();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut env = match jvm.attach_current_thread() {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(target: "chanora_audio", error = %e, "android: attach_current_thread failed; effects not bound");
|
||||||
|
return HardwareEffectHandles::default();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let mut handles = HardwareEffectHandles::default();
|
let mut handles = HardwareEffectHandles::default();
|
||||||
if effects.aec {
|
if effects.aec {
|
||||||
handles.aec = create_effect(
|
handles.aec = create_effect(
|
||||||
env,
|
&mut env,
|
||||||
"android/media/audiofx/AcousticEchoCanceler",
|
"android/media/audiofx/AcousticEchoCanceler",
|
||||||
session_id,
|
session_id,
|
||||||
"AEC",
|
"AEC",
|
||||||
@@ -1326,7 +1321,7 @@ fn attach_hardware_effects_inner(
|
|||||||
}
|
}
|
||||||
if effects.noise_suppression {
|
if effects.noise_suppression {
|
||||||
handles.ns = create_effect(
|
handles.ns = create_effect(
|
||||||
env,
|
&mut env,
|
||||||
"android/media/audiofx/NoiseSuppressor",
|
"android/media/audiofx/NoiseSuppressor",
|
||||||
session_id,
|
session_id,
|
||||||
"NS",
|
"NS",
|
||||||
@@ -1334,51 +1329,20 @@ fn attach_hardware_effects_inner(
|
|||||||
}
|
}
|
||||||
if effects.agc {
|
if effects.agc {
|
||||||
handles.agc = create_effect(
|
handles.agc = create_effect(
|
||||||
env,
|
&mut env,
|
||||||
"android/media/audiofx/AutomaticGainControl",
|
"android/media/audiofx/AutomaticGainControl",
|
||||||
session_id,
|
session_id,
|
||||||
"AGC",
|
"AGC",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
handles
|
handles
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_android_env<R>(
|
|
||||||
operation: &str,
|
|
||||||
op: impl for<'local> FnOnce(&mut jni::Env<'local>) -> R,
|
|
||||||
) -> Option<R> {
|
|
||||||
let ctx = ndk_context::android_context();
|
|
||||||
if ctx.vm().is_null() {
|
|
||||||
warn!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
operation,
|
|
||||||
"android: ndk_context vm null; JNI call skipped"
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let jvm = unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) };
|
|
||||||
match jvm.attach_current_thread(|env| Ok::<R, jni::errors::Error>(op(env))) {
|
|
||||||
Ok(value) => Some(value),
|
|
||||||
Err(e) => {
|
|
||||||
warn!(target: "chanora_audio", error = %e, operation, "android: attach_current_thread failed");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 +1360,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 +1383,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 +1411,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 +1448,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();
|
||||||
|
if ctx.vm().is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: vm is non-null and owned for process lifetime via JNI_OnLoad.
|
||||||
|
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let mut env = match jvm.attach_current_thread() {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
||||||
if let Some(g) = effect {
|
if let Some(g) = effect {
|
||||||
let _ = env.call_method(
|
let _ = env.call_method(
|
||||||
g.as_obj(),
|
g.as_obj(),
|
||||||
jni::jni_str!("setEnabled"),
|
"setEnabled",
|
||||||
jni::jni_sig!("(Z)I"),
|
"(Z)I",
|
||||||
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
||||||
);
|
);
|
||||||
env.exception_clear();
|
let _ = env.exception_clear();
|
||||||
let _ = env.call_method(
|
let _ = env.call_method(g.as_obj(), "release", "()V", &[]);
|
||||||
g.as_obj(),
|
let _ = env.exception_clear();
|
||||||
jni::jni_str!("release"),
|
|
||||||
jni::jni_sig!("()V"),
|
|
||||||
&[],
|
|
||||||
);
|
|
||||||
env.exception_clear();
|
|
||||||
drop(g);
|
drop(g);
|
||||||
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
|
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 +1550,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,19 +1558,34 @@ 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
|
||||||
|
// the process lifetime.
|
||||||
|
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(target: "chanora_audio", error = %e, method, "android: JavaVM::from_raw failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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
|
// SAFETY: ndk_context::context() is the application Context
|
||||||
// jobject; valid global ref for process lifetime.
|
// jobject; valid global ref for process lifetime.
|
||||||
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||||
let class = match load_app_class(env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
let class = match load_app_class(&mut env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
match env.call_static_method(
|
match env.call_static_method(
|
||||||
&class,
|
&class,
|
||||||
jni::strings::JNIString::new(method),
|
method,
|
||||||
jni::jni_sig!("(Landroid/content/Context;)V"),
|
"(Landroid/content/Context;)V",
|
||||||
&[JValue::Object(&context_obj)],
|
&[JValue::Object(&context_obj)],
|
||||||
) {
|
) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -1616,21 +1593,19 @@ fn call_voice_service_static(method: &str) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
env.exception_clear();
|
let _ = env.exception_clear();
|
||||||
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
|
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.unwrap_or(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 +1616,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 +1642,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 +1679,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 +1717,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 +1766,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,17 +1775,29 @@ fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||||
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");
|
||||||
|
return 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,
|
Some(c) => c,
|
||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
match env.call_static_method(
|
match env.call_static_method(
|
||||||
&class,
|
&class,
|
||||||
jni::strings::JNIString::new(method),
|
method,
|
||||||
jni::jni_sig!("(Landroid/content/Context;)V"),
|
"(Landroid/content/Context;)V",
|
||||||
&[JValue::Object(&context_obj)],
|
&[JValue::Object(&context_obj)],
|
||||||
) {
|
) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -1825,11 +1805,9 @@ fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
env.exception_clear();
|
let _ = env.exception_clear();
|
||||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
|
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.unwrap_or(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,12 @@ 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
|
// TODO: Wire to client disconnect path; handled in callback but no
|
||||||
// producer currently pushes this command.
|
// producer currently pushes this command.
|
||||||
#[allow(dead_code)]
|
|
||||||
RemoveClient(SessionAudioId),
|
RemoveClient(SessionAudioId),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,64 +118,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"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -981,6 +981,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 +1085,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 +1246,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 +1278,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 +1292,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);
|
|
||||||
match res {
|
|
||||||
Ok(_) => {
|
|
||||||
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||||
}
|
} else {
|
||||||
|
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => break,
|
None => break,
|
||||||
@@ -1339,43 +1320,6 @@ impl AudioEngine {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
fn rollback_android_startup_resources(audio_mode_stack: &mut crate::mode_stack::ModeStack) {
|
|
||||||
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
|
|
||||||
crate::android_voice_unit::chanora_android_abandon_audio_focus();
|
|
||||||
match release_android_audio_mode_for_startup_rollback(audio_mode_stack) {
|
|
||||||
crate::mode_stack::ModeRelease::LastRelease { prior } => {
|
|
||||||
match android_set_audio_mode(prior) {
|
|
||||||
Ok(()) => info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
restored_mode = prior,
|
|
||||||
"android: AudioManager mode restored during startup rollback"
|
|
||||||
),
|
|
||||||
Err(e) => warn!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
error = %e,
|
|
||||||
restored_mode = prior,
|
|
||||||
"android: failed to restore AudioManager mode during startup rollback"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crate::mode_stack::ModeRelease::StillHeld => {
|
|
||||||
info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
"android: audio mode still held during startup rollback"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
crate::mode_stack::ModeRelease::AlreadyReleased => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if crate::android_voice_unit::chanora_android_stop_voice_service() {
|
|
||||||
info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
"android: voice foreground service stopped during startup rollback"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop the engine. Idempotent.
|
/// 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() {
|
||||||
@@ -1770,33 +1714,6 @@ impl Drop for AudioEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(test, target_os = "android"))]
|
|
||||||
fn release_android_audio_mode_for_startup_rollback(
|
|
||||||
audio_mode_stack: &mut crate::mode_stack::ModeStack,
|
|
||||||
) -> crate::mode_stack::ModeRelease {
|
|
||||||
audio_mode_stack.release()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn android_startup_rollback_releases_acquired_mode_snapshot() {
|
|
||||||
let mut stack = crate::mode_stack::ModeStack::new();
|
|
||||||
let _ = stack.acquire(7);
|
|
||||||
|
|
||||||
let release = release_android_audio_mode_for_startup_rollback(&mut stack);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
release,
|
|
||||||
crate::mode_stack::ModeRelease::LastRelease { prior: 7 }
|
|
||||||
);
|
|
||||||
assert_eq!(stack.refcount(), 0);
|
|
||||||
assert_eq!(stack.snapshot(), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Capture pipeline ----------
|
// ---------- 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")))]
|
||||||
@@ -1839,12 +1756,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,
|
||||||
|
frames_sent,
|
||||||
audio_processing_stats,
|
audio_processing_stats,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
@@ -1879,10 +1793,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
|
||||||
@@ -1897,26 +1812,8 @@ struct CaptureState {
|
|||||||
/// 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>,
|
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,8 +1821,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>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -1939,13 +1837,10 @@ 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,
|
||||||
mono_scratch: Vec::with_capacity(4096),
|
mono_scratch: Vec::with_capacity(4096),
|
||||||
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
|
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
|
||||||
audio_processing_stats,
|
audio_processing_stats,
|
||||||
// Start in the past so the first ingest emits immediately.
|
|
||||||
last_level_emit: std::time::Instant::now()
|
|
||||||
.checked_sub(LEVEL_METER_INTERVAL)
|
|
||||||
.unwrap_or_else(std::time::Instant::now),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1965,15 +1860,10 @@ impl CaptureState {
|
|||||||
self.mono_scratch.push(sum / frame.len() as f32);
|
self.mono_scratch.push(sum / frame.len() as f32);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Level meter: pay sqrt() + log10() only when at least
|
// Measure dBFS from pre-gain samples so the level meter
|
||||||
// LEVEL_METER_INTERVAL has elapsed, regardless of the
|
// reflects the raw mic input, not the amplified signal.
|
||||||
// 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
|
self.audio_processing_stats
|
||||||
.set_input_dbfs(crate::frame::dbfs(&self.mono_scratch));
|
.set_input_dbfs(crate::frame::dbfs(&self.mono_scratch));
|
||||||
}
|
|
||||||
|
|
||||||
if mic_gain != 1.0 {
|
if mic_gain != 1.0 {
|
||||||
for s in &mut self.mono_scratch {
|
for s in &mut self.mono_scratch {
|
||||||
@@ -2035,6 +1925,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 +2339,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 +2346,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,9 +2363,13 @@ 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 context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||||
|
|
||||||
let service_name: JString =
|
let service_name: JString =
|
||||||
env.new_string("audio")
|
env.new_string("audio")
|
||||||
@@ -2492,13 +2377,12 @@ where
|
|||||||
method: "new_string",
|
method: "new_string",
|
||||||
detail: e.to_string(),
|
detail: e.to_string(),
|
||||||
})?;
|
})?;
|
||||||
let service_name_obj = JObject::from(service_name);
|
|
||||||
let audio_manager = env
|
let audio_manager = env
|
||||||
.call_method(
|
.call_method(
|
||||||
&context_obj,
|
&context_obj,
|
||||||
jni::jni_str!("getSystemService"),
|
"getSystemService",
|
||||||
jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Object;"),
|
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||||
&[JValue::Object(&service_name_obj)],
|
&[JValue::Object(&service_name.into())],
|
||||||
)
|
)
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
method: "getSystemService",
|
method: "getSystemService",
|
||||||
@@ -2514,8 +2398,7 @@ where
|
|||||||
"AudioManager service is null".to_string(),
|
"AudioManager service is null".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
op(env, &audio_manager)
|
op(&mut env, &audio_manager)
|
||||||
})
|
|
||||||
});
|
});
|
||||||
match result {
|
match result {
|
||||||
Ok(inner) => inner,
|
Ok(inner) => inner,
|
||||||
@@ -2534,12 +2417,7 @@ 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,
|
|
||||||
jni::jni_str!("getMode"),
|
|
||||||
jni::jni_sig!("()I"),
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
method: "getMode",
|
method: "getMode",
|
||||||
detail: e.to_string(),
|
detail: e.to_string(),
|
||||||
@@ -2562,12 +2440,7 @@ 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,
|
|
||||||
jni::jni_str!("setMode"),
|
|
||||||
jni::jni_sig!("(I)V"),
|
|
||||||
&[JValue::Int(mode)],
|
|
||||||
)
|
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
method: "setMode",
|
method: "setMode",
|
||||||
detail: e.to_string(),
|
detail: e.to_string(),
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
|
||||||
|
if self.silero_coreml_worker.is_none() {
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
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,16 +325,7 @@ 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,
|
|
||||||
vad_backend,
|
|
||||||
self.silero_coreml_worker.is_some(),
|
|
||||||
) {
|
|
||||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
|
||||||
let worker = self
|
|
||||||
.silero_coreml_worker
|
|
||||||
.as_ref()
|
|
||||||
.expect("policy checked worker");
|
|
||||||
let enqueued = worker.try_send(capture_seq, &frame);
|
let enqueued = worker.try_send(capture_seq, &frame);
|
||||||
if !worker.is_stale(capture_seq) {
|
if !worker.is_stale(capture_seq) {
|
||||||
let p = worker.latest_probability();
|
let p = worker.latest_probability();
|
||||||
@@ -316,8 +346,7 @@ mod inner {
|
|||||||
&frame,
|
&frame,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
|
||||||
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,11 +354,6 @@ mod inner {
|
|||||||
&frame,
|
&frame,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
|
||||||
probability: 1.0,
|
|
||||||
speech: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} 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 =
|
||||||
|
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
|
||||||
|
if self.silero_coreml_worker.is_none() {
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
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,16 +436,7 @@ 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,
|
|
||||||
vad_backend,
|
|
||||||
self.silero_coreml_worker.is_some(),
|
|
||||||
) {
|
|
||||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
|
||||||
let worker = self
|
|
||||||
.silero_coreml_worker
|
|
||||||
.as_ref()
|
|
||||||
.expect("policy checked worker");
|
|
||||||
let enqueued = worker.try_send(capture_seq, &frame);
|
let enqueued = worker.try_send(capture_seq, &frame);
|
||||||
if !worker.is_stale(capture_seq) {
|
if !worker.is_stale(capture_seq) {
|
||||||
let p = worker.latest_probability();
|
let p = worker.latest_probability();
|
||||||
@@ -436,19 +457,10 @@ impl IosCaptureState {
|
|||||||
&frame,
|
&frame,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
|
||||||
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(&mut self.vad_detector, &frame)
|
||||||
&mut self.vad_detector,
|
|
||||||
&frame,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
|
||||||
probability: 1.0,
|
|
||||||
speech: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
} 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,200 +772,44 @@ 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):
|
|
||||||
//
|
|
||||||
// The render callback is the wrong place to call AudioHandler::fill_buffer.
|
|
||||||
// macOS VPIO invokes us with `num_frames=512` (= 10.67 ms @ 48 kHz) which
|
|
||||||
// is NOT a multiple of the 20 ms Opus frame size that AudioHandler expects
|
|
||||||
// inside fill_buffer. The mismatch (a) leaves fill_buffer unable to
|
|
||||||
// satisfy the request on most calls and (b) advances MAX_PACKET_LOSSES,
|
|
||||||
// which removes the talker and starts PLC silence/decay.
|
|
||||||
//
|
|
||||||
// Converged fix across Mumble (Speex jitter buffer + decoded PCM FIFO),
|
|
||||||
// WebRTC NetEQ (adaptive 80ms prebuffer + sync buffer), Songbird (Fill→
|
|
||||||
// Drain playout buffer), cpal/rodio (SPSC ring + zero-fill on underrun),
|
|
||||||
// tsclientlib's own SDL example, and the upstream tsclientlib::audio
|
|
||||||
// contract: separate ingress quantum (20 ms decoded PCM = 1920 stereo
|
|
||||||
// f32) from egress quantum (whatever VPIO asks for), connected by a
|
|
||||||
// lock-free ring of decoded PCM.
|
|
||||||
//
|
|
||||||
// Layout:
|
|
||||||
// * macOS: spawn a tokio producer task paced at 20 ms; each tick
|
|
||||||
// try-locks AudioHandler, calls fill_buffer(1920), pushes 1920
|
|
||||||
// f32 into the ring. The render callback only pops — no lock,
|
|
||||||
// no Opus decode, no allocator on the audio thread. 60 ms
|
|
||||||
// prebuffer (3 × 20 ms) is held before the callback starts
|
|
||||||
// draining, matching Mumble's playout margin and WebRTC's
|
|
||||||
// kStartDelayMs order of magnitude. Crossbeam ArrayQueue
|
|
||||||
// is used because we already depend on crossbeam-queue.
|
|
||||||
// * iOS: keep the existing direct fill_buffer path — VPIO on iOS
|
|
||||||
// requests 480-frame slices that ARE 20 ms aligned so the
|
|
||||||
// cadence mismatch does not arise there.
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let producer_shutdown = Arc::new(AtomicBool::new(false));
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
// 100 ms capacity = 9600 stereo f32. Sized so that the 60 ms
|
|
||||||
// prebuffer plus a few jitter spikes fit without forcing the
|
|
||||||
// producer to drop frames. crossbeam ArrayQueue is fixed-cap
|
|
||||||
// and lock-free SPSC-ish (MPMC but wait-free per end); for
|
|
||||||
// single producer + single consumer it's effectively SPSC.
|
|
||||||
const RING_CAPACITY: usize = 12000;
|
|
||||||
const PULL_SAMPLES: usize = 1920; // one 20 ms Opus frame, stereo
|
|
||||||
const PREBUFFER_SAMPLES: usize = 9600; // 100 ms @ 48 kHz stereo
|
|
||||||
|
|
||||||
let pcm_ring: Arc<ArrayQueue<f32>> = Arc::new(ArrayQueue::new(RING_CAPACITY));
|
|
||||||
let pcm_ring_producer = pcm_ring.clone();
|
|
||||||
let pcm_ring_consumer = pcm_ring.clone();
|
|
||||||
let handler_for_producer = params.handler.clone();
|
|
||||||
let output_gain_for_render = params.output_gain.clone();
|
|
||||||
let output_muted_for_render = params.output_muted.clone();
|
|
||||||
|
|
||||||
let producer_shutdown_for_task = producer_shutdown.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut pull_scratch: Vec<f32> = vec![0.0; PULL_SAMPLES];
|
|
||||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
|
||||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
if producer_shutdown_for_task.load(Ordering::Relaxed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match handler_for_producer.try_lock() {
|
|
||||||
Ok(mut h) => {
|
|
||||||
pull_scratch.fill(0.0);
|
|
||||||
let _ = h.fill_buffer(&mut pull_scratch[..]);
|
|
||||||
for &s in &pull_scratch {
|
|
||||||
pcm_ring_producer.force_push(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(std::sync::TryLockError::WouldBlock) => {}
|
|
||||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
|
||||||
warn!(target: "chanora_audio",
|
|
||||||
"producer: AudioHandler mutex poisoned: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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 handler_for_render = params.handler.clone();
|
||||||
let output_gain_for_render = params.output_gain.clone();
|
let output_gain_for_render = params.output_gain.clone();
|
||||||
let output_muted_for_render = params.output_muted.clone();
|
let output_muted_for_render = params.output_muted.clone();
|
||||||
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
||||||
// Level meter decimation: the render callback fires ~93
|
let wav_recorder_for_render = wav_recorder.clone();
|
||||||
// times/sec, but the bridge consumer reads at ~30 Hz.
|
// Diagnostic counters (sampled every 100 callbacks ~= 2 s).
|
||||||
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 cb_count: u64 = 0;
|
||||||
let mut last_num_frames: usize = 0;
|
let mut last_num_frames: usize = 0;
|
||||||
let mut num_frames_changes: u64 = 0;
|
let mut num_frames_changes: u32 = 0;
|
||||||
let mut callbacks_with_audio: u64 = 0;
|
let mut callbacks_with_audio: u64 = 0;
|
||||||
let mut callbacks_with_silence: u64 = 0;
|
let mut callbacks_with_silence: u64 = 0;
|
||||||
|
let mut render_ref_accum = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
|
let mut render_ref_len: usize = 0;
|
||||||
|
let mut render_recorder_active = false;
|
||||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
let render_callback::Args {
|
let out: &mut [i16] = args.data.buffer;
|
||||||
data, num_frames, ..
|
let num_frames = out.len();
|
||||||
} = args;
|
// AudioHandler produces 48 kHz stereo f32 (= num_frames * 2 floats).
|
||||||
let out: &mut [i16] = data.buffer;
|
let needed = num_frames * 2;
|
||||||
let out_channels = data.channels;
|
if scratch_stereo.len() < needed {
|
||||||
let process_frames = num_frames.min(IOS_RENDER_SCRATCH_FRAMES);
|
scratch_stereo.resize(needed, 0.0);
|
||||||
if process_frames < num_frames {
|
|
||||||
audio_processing_stats_for_render.increment_callback_xrun();
|
|
||||||
}
|
}
|
||||||
// AudioHandler produces 48 kHz stereo f32 (= frames * 2 floats).
|
|
||||||
let needed = process_frames * 2;
|
|
||||||
// Zero the live slice. AudioHandler::fill_buffer is
|
// Zero the live slice. AudioHandler::fill_buffer is
|
||||||
// additive (does NOT clear); residual values from
|
// additive (does NOT clear); residual values from
|
||||||
// earlier callbacks (when scratch was bigger) would
|
// earlier callbacks (when scratch was bigger) would
|
||||||
// leak through otherwise.
|
// leak through otherwise.
|
||||||
scratch_stereo[..needed].fill(0.0);
|
scratch_stereo[..needed].fill(0.0);
|
||||||
|
// Non-blocking fill on the realtime callback thread.
|
||||||
|
// If the inbound forwarder currently owns this mutex,
|
||||||
|
// emit this period as silence instead of blocking and
|
||||||
|
// risking an AudioUnit underrun pop/click.
|
||||||
match handler_for_render.try_lock() {
|
match handler_for_render.try_lock() {
|
||||||
Ok(mut h) => {
|
Ok(mut h) => {
|
||||||
let _ = h.fill_buffer(&mut scratch_stereo[..needed]);
|
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
|
||||||
}
|
}
|
||||||
Err(std::sync::TryLockError::WouldBlock) => {
|
Err(std::sync::TryLockError::WouldBlock) => {
|
||||||
audio_processing_stats_for_render.increment_callback_xrun();
|
audio_processing_stats_for_render.increment_callback_xrun();
|
||||||
@@ -1000,41 +820,54 @@ impl IosVoiceUnit {
|
|||||||
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
|
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 gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||||
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[..needed],
|
&scratch_stereo[..needed],
|
||||||
out,
|
out,
|
||||||
out_channels,
|
|
||||||
gain,
|
gain,
|
||||||
muted,
|
muted,
|
||||||
);
|
);
|
||||||
if mix_stats.clipped_samples > 0 {
|
if mix_stats.clipped_samples > 0 {
|
||||||
audio_processing_stats_for_render
|
audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples);
|
||||||
.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(
|
audio_processing_stats_for_render.update_render(
|
||||||
crate::frame::dbfs(&scratch_stereo[..needed]),
|
crate::frame::dbfs(&scratch_stereo[..needed]),
|
||||||
num_frames as u32,
|
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 {
|
||||||
|
render_recorder_active = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
render_recorder_active = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track audio-vs-silence for the diagnostic.
|
// Track audio-vs-silence for the diagnostic.
|
||||||
if mix_stats.peak_i16 > 0 {
|
if mix_stats.peak_i16 > 0 {
|
||||||
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
||||||
} else {
|
} else {
|
||||||
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
|
||||||
// Muted output writes intentional silence (peak_i16 == 0 by
|
|
||||||
// design), not a starved render path. Gate on !muted to avoid
|
|
||||||
// counting deliberate silence as an output underrun.
|
|
||||||
if !muted {
|
|
||||||
audio_processing_stats_for_render.increment_output_underrun();
|
audio_processing_stats_for_render.increment_output_underrun();
|
||||||
}
|
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostic sampling.
|
// Diagnostic sampling.
|
||||||
@@ -1059,7 +892,6 @@ impl IosVoiceUnit {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||||
} // end #[cfg(target_os = "ios")] block
|
|
||||||
|
|
||||||
// Finalise the unit — allocates internal buffers per the
|
// 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;
|
||||||
|
|||||||
@@ -67,7 +67,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 +85,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,50 +108,34 @@ 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}"),
|
||||||
|
Self::PermissionDenied => f.write_str("permission denied"),
|
||||||
|
Self::UnsupportedEnvironment => f.write_str("unsupported environment"),
|
||||||
|
Self::InvalidBinding(s) => write!(f, "invalid binding: {s}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
impl std::error::Error for PttBackendError {}
|
||||||
fn ptt_backend_error_display_strings_stay_stable() {
|
|
||||||
assert_eq!(
|
|
||||||
PttBackendError::Init("rawinput".into()).to_string(),
|
|
||||||
"init failed: rawinput"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
PttBackendError::PermissionDenied.to_string(),
|
|
||||||
"permission denied"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
PttBackendError::UnsupportedEnvironment.to_string(),
|
|
||||||
"unsupported environment"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
PttBackendError::InvalidBinding("bad key".into()).to_string(),
|
|
||||||
"invalid binding: bad key"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cross-platform desktop PTT backend (SDD-081).
|
/// Cross-platform desktop PTT backend (SDD-081).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1972,77 +1972,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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -87,30 +84,6 @@ async fn send_with_timeout<T: Send>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn drain_voice_packets_for_tick<T, E>(
|
|
||||||
voice_out_rx: &mut mpsc::Receiver<T>,
|
|
||||||
max_packets: usize,
|
|
||||||
mut send: impl FnMut(T) -> Result<(), E>,
|
|
||||||
) -> usize {
|
|
||||||
let mut drained = 0;
|
|
||||||
for _ in 0..max_packets {
|
|
||||||
let packet = match voice_out_rx.try_recv() {
|
|
||||||
Ok(packet) => packet,
|
|
||||||
Err(_) => break,
|
|
||||||
};
|
|
||||||
let _ = send(packet);
|
|
||||||
drained += 1;
|
|
||||||
}
|
|
||||||
drained
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn bounded_drain_stream<S>(stream: S, timeout_duration: Duration)
|
|
||||||
where
|
|
||||||
S: futures::Stream,
|
|
||||||
{
|
|
||||||
let _ = tokio::time::timeout(timeout_duration, stream.for_each(|_| future::ready(()))).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pick the TeamSpeak `client_version`/platform/signature triple
|
/// 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*
|
||||||
@@ -368,22 +341,9 @@ 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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move our own client into a channel. `password` is optional
|
/// Move our own client into a channel. `password` is optional
|
||||||
@@ -716,14 +676,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,7 +689,8 @@ 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))) => {
|
||||||
|
match item {
|
||||||
StreamItem::Audio(buf) => {
|
StreamItem::Audio(buf) => {
|
||||||
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
||||||
}
|
}
|
||||||
@@ -743,7 +702,8 @@ async fn connection_task(
|
|||||||
&channels.delta,
|
&channels.delta,
|
||||||
&mut pending_moves,
|
&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
|
||||||
@@ -847,7 +807,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 +815,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);
|
||||||
}
|
}
|
||||||
@@ -967,9 +927,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 {
|
||||||
@@ -1167,15 +1125,7 @@ async fn fetch_client_profile(
|
|||||||
) -> 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}")))?;
|
||||||
@@ -1264,7 +1214,13 @@ 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(
|
||||||
|
con,
|
||||||
|
database_id,
|
||||||
|
channels,
|
||||||
|
pending_moves,
|
||||||
|
voice_activity,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
} else {
|
} else {
|
||||||
@@ -1333,18 +1289,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 +1325,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)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1924,12 +1876,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 +2122,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(
|
||||||
@@ -2331,7 +2204,9 @@ fn forward_delta(
|
|||||||
old: PropertyValue::Channel(channel),
|
old: PropertyValue::Channel(channel),
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved { id: channel.id.0 });
|
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved {
|
||||||
|
id: channel.id.0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Event::PropertyChanged {
|
Event::PropertyChanged {
|
||||||
id: PropertyId::Channel(channel_id),
|
id: PropertyId::Channel(channel_id),
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ Chanora is a Flutter application with a Rust core. Flutter owns the user-facing
|
|||||||
| Flutter service layer | `apps/chanora_flutter/lib/services/` | Permission flows, lifecycle policy, host prefetch debounce, link trust, state mapping, platform back intent | Flutter app shell, generated bridge APIs, platform plugins |
|
| Flutter service layer | `apps/chanora_flutter/lib/services/` | Permission flows, lifecycle policy, host prefetch debounce, link trust, state mapping, platform back intent | Flutter app shell, generated bridge APIs, platform plugins |
|
||||||
| Flutter widget layer | `apps/chanora_flutter/lib/widgets/` | Connect UI, channel tree, chat, voice controls, settings, diagnostics surfaces | Flutter services, generated DTOs, design tokens |
|
| Flutter widget layer | `apps/chanora_flutter/lib/widgets/` | Connect UI, channel tree, chat, voice controls, settings, diagnostics surfaces | Flutter services, generated DTOs, design tokens |
|
||||||
| Bridge layer | `crates/chanora_bridge`, `apps/chanora_flutter/lib/src/rust/` | Typed Flutter/Rust boundary and generated bindings | Rust core, Flutter generated code |
|
| Bridge layer | `crates/chanora_bridge`, `apps/chanora_flutter/lib/src/rust/` | Typed Flutter/Rust boundary and generated bindings | Rust core, Flutter generated code |
|
||||||
| Rust core | `core/chanora_core` | Connection lifecycle, orchestration, reconnect behavior, storage coordination, voice state, bridge-facing event DTOs | Protocol, audio, storage, diagnostics, state, resolver/prefetch |
|
| Rust core | `core/chanora_core` | Connection lifecycle, orchestration, reconnect behavior, storage coordination, voice state | Protocol, audio, storage, diagnostics, state, resolver/prefetch |
|
||||||
| Protocol adapter | `crates/chanora_protocol` | Isolate `tsclientlib`, expose typed protocol DTOs/errors | Rust core, external compatible server |
|
| Protocol adapter | `crates/chanora_protocol` | Isolate `tsclientlib`, expose typed protocol DTOs/errors | Rust core, external compatible server |
|
||||||
| State sync | `crates/chanora_state` | Snapshot/delta model, channel join helpers, reducer behavior | Rust core, protocol DTOs |
|
| State sync | `crates/chanora_state` | Snapshot/delta model, channel join helpers, reducer behavior | Rust core, protocol DTOs |
|
||||||
| Audio subsystem | `crates/chanora_audio` | Capture/playback, Opus, DSP, PTT, voice activity reservation, platform units | Rust core, platform APIs, protocol audio path |
|
| Audio subsystem | `crates/chanora_audio` | Capture/playback, Opus, DSP, PTT, voice activity reservation, platform units | Rust core, platform APIs, protocol audio path |
|
||||||
@@ -68,8 +68,6 @@ Flutter UI/widgets/services
|
|||||||
|
|
||||||
The bridge is the trust and type boundary between Flutter and Rust. Flutter must not directly depend on protocol-library internals. Rust core must not expose platform-specific storage or audio details to UI code except through stable DTOs and capability fields.
|
The bridge is the trust and type boundary between Flutter and Rust. Flutter must not directly depend on protocol-library internals. Rust core must not expose platform-specific storage or audio details to UI code except through stable DTOs and capability fields.
|
||||||
|
|
||||||
Current Core locality note: the public Core Interface remains available through `chanora_core::*` re-exports, while branch `simplify-project-review` has started moving internal Core responsibilities into focused Modules (`events.rs`, `network_diagnostics.rs`). This is an internal maintainability split, not a public Interface change.
|
|
||||||
|
|
||||||
## 6. Runtime Flow Architecture
|
## 6. Runtime Flow Architecture
|
||||||
|
|
||||||
### 6.1 Connect Flow
|
### 6.1 Connect Flow
|
||||||
@@ -182,8 +180,6 @@ This SAD derives only from `docs/srs.md`. The broad SRS group-to-component alloc
|
|||||||
| SAD item numbering from historical status references is not reconstructed in this baseline | Existing references such as `SAD-043` and `SAD-046` are not itemized here | Treat this as a DV baseline SAD; add itemized SAD IDs in a follow-up if process requires strict ID-level review |
|
| SAD item numbering from historical status references is not reconstructed in this baseline | Existing references such as `SAD-043` and `SAD-046` are not itemized here | Treat this as a DV baseline SAD; add itemized SAD IDs in a follow-up if process requires strict ID-level review |
|
||||||
| Some architecture views are textual rather than C4 diagrams | Reviewers may request visual C4 views | Record as documentation hardening, not a blocker for DV baseline if textual views are accepted |
|
| Some architecture views are textual rather than C4 diagrams | Reviewers may request visual C4 views | Record as documentation hardening, not a blocker for DV baseline if textual views are accepted |
|
||||||
| Release/platform architecture evidence is incomplete | Public release remains blocked | Controlled by release-readiness and waiver records |
|
| Release/platform architecture evidence is incomplete | Public release remains blocked | Controlled by release-readiness and waiver records |
|
||||||
| Android runtime verification is not automatic in local reviews | Android permission/audio/lifecycle regressions can pass Rust-only tests | Require `adb devices -l` with a connected device/emulator and Android smoke evidence before claiming Android runtime success |
|
|
||||||
| Protocol voice packet re-export is an intentional exception to full protocol isolation | Future changes may accidentally widen the protocol/audio Seam | Document and keep the voice wire exception narrow, or move packet construction fully into `chanora_protocol` |
|
|
||||||
|
|
||||||
## 14. DV Conclusion
|
## 14. DV Conclusion
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ This Software Detailed Design defines the module-level design details needed for
|
|||||||
| SDD-MOD-005 Voice UI | `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart`, `voice_level_meter.dart`, `ptt_capability_badge.dart` | Voice controls, processing settings, metering, PTT capability | Flutter widget layer |
|
| SDD-MOD-005 Voice UI | `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart`, `voice_level_meter.dart`, `ptt_capability_badge.dart` | Voice controls, processing settings, metering, PTT capability | Flutter widget layer |
|
||||||
| SDD-MOD-006 Platform services | `android_permissions_service.dart`, `ios_permissions_service.dart`, `audio_lifecycle_service.dart`, `back_intent_*`, `link_trust_service.dart` | Permission, lifecycle, navigation, route/link trust behavior | Flutter service layer |
|
| SDD-MOD-006 Platform services | `android_permissions_service.dart`, `ios_permissions_service.dart`, `audio_lifecycle_service.dart`, `back_intent_*`, `link_trust_service.dart` | Permission, lifecycle, navigation, route/link trust behavior | Flutter service layer |
|
||||||
| SDD-MOD-007 Bridge API | `crates/chanora_bridge/src/api.rs`, generated Dart/Rust bridge files | Typed command/event boundary | Bridge layer |
|
| SDD-MOD-007 Bridge API | `crates/chanora_bridge/src/api.rs`, generated Dart/Rust bridge files | Typed command/event boundary | Bridge layer |
|
||||||
| SDD-MOD-008 Rust core supervisor | `core/chanora_core/src/lib.rs`, `events.rs`, `network_diagnostics.rs`, `ptt.rs` | Connection orchestration, reconnect, bridge-facing event DTOs, network diagnostics, PTT state, storage coordination | Rust core |
|
| SDD-MOD-008 Rust core supervisor | `core/chanora_core/src/lib.rs`, `ptt.rs` | Connection orchestration, reconnect, PTT state, storage coordination | Rust core |
|
||||||
| SDD-MOD-009 Protocol adapter | `crates/chanora_protocol/src/` | `tsclientlib` isolation, DTO/error mapping | Protocol adapter |
|
| SDD-MOD-009 Protocol adapter | `crates/chanora_protocol/src/` | `tsclientlib` isolation, DTO/error mapping | Protocol adapter |
|
||||||
| SDD-MOD-010 State sync | `crates/chanora_state/src/lib.rs`, `channel_join.rs` | Snapshot/delta model, reducer, channel join support | State sync |
|
| SDD-MOD-010 State sync | `crates/chanora_state/src/lib.rs`, `channel_join.rs` | Snapshot/delta model, reducer, channel join support | State sync |
|
||||||
| SDD-MOD-011 Audio subsystem | `crates/chanora_audio/src/` | Audio capture/playback, DSP, Opus, PTT, mode stack, platform units | Audio subsystem |
|
| SDD-MOD-011 Audio subsystem | `crates/chanora_audio/src/` | Audio capture/playback, DSP, Opus, PTT, mode stack, platform units | Audio subsystem |
|
||||||
@@ -50,7 +50,6 @@ Design rules:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Connection lifecycle | Rust core owns connect/disconnect/reconnect decisions and suppresses reconnect after user disconnect |
|
| Connection lifecycle | Rust core owns connect/disconnect/reconnect decisions and suppresses reconnect after user disconnect |
|
||||||
| Backoff | Reconnect uses exponential backoff as described in implementation status, capped at 60 seconds |
|
| Backoff | Reconnect uses exponential backoff as described in implementation status, capped at 60 seconds |
|
||||||
| Core internal Modules | `lib.rs` remains the public Interface and orchestration entry point; `events.rs` owns public event/bridge-facing DTOs re-exported by `lib.rs`; `network_diagnostics.rs` owns private connect/loss counters and the last-loss ring buffer |
|
|
||||||
| Server resolution | Resolver performs SRV/TSDNS/DNS fallback; prefetch cache may warm but must not be required for connect success |
|
| Server resolution | Resolver performs SRV/TSDNS/DNS fallback; prefetch cache may warm but must not be required for connect success |
|
||||||
| Snapshot mapping | Rust state and bridge DTOs are mapped into Flutter view models by `snapshot_state_mapper.dart` |
|
| Snapshot mapping | Rust state and bridge DTOs are mapped into Flutter view models by `snapshot_state_mapper.dart` |
|
||||||
| Channel join | Channel join logic and errors are represented through Rust state/protocol handling and Flutter error mapper service |
|
| Channel join | Channel join logic and errors are represented through Rust state/protocol handling and Flutter error mapper service |
|
||||||
@@ -64,13 +63,8 @@ Design rules:
|
|||||||
| Codec | Opus encode/decode lives in `opus_voice.rs` and associated audio modules |
|
| Codec | Opus encode/decode lives in `opus_voice.rs` and associated audio modules |
|
||||||
| DSP chain | High-pass filter, noise suppression, echo cancellation, and AGC are represented by audio processing modules/backends |
|
| DSP chain | High-pass filter, noise suppression, echo cancellation, and AGC are represented by audio processing modules/backends |
|
||||||
| Transmit control | `TransmitMode` supports `Ptt`, `Continuous`, and reserved `VoiceActivity`; `VoiceActivity` has no active MVP implementation |
|
| Transmit control | `TransmitMode` supports `Ptt`, `Continuous`, and reserved `VoiceActivity`; `VoiceActivity` has no active MVP implementation |
|
||||||
| VoiceActivity gate (capture-side) | `voice_activity::VoiceActivityStateMachine` is the 10 ms-cadence gate for `TransmitMode::VoiceActivity`; open-after 40 ms (debounce), hangover 500 ms (anti-chatter), min-tx 200 ms (anti-flicker), weak-hold 30-100 frames (anti-stale-VAD); live `configure()` re-clamps existing timers on settings change without resetting state; 9 unit tests cover the main paths |
|
|
||||||
| PTT | Desktop/mobile backends expose capability level and active backend; missed-key-up watchdog prevents stuck transmit |
|
| PTT | Desktop/mobile backends expose capability level and active backend; missed-key-up watchdog prevents stuck transmit |
|
||||||
| Release tail | Tail handling prevents abrupt cutoffs after PTT release where configured |
|
| Release tail | Tail handling prevents abrupt cutoffs after PTT release where configured |
|
||||||
| Render peak limiter | `voice_render::limit_peak_inplace` is a single-pass, allocation-free per-frame peak scaler applied in both the macOS and iOS render callbacks before the i16 downmix; default threshold 0.99 prevents hard clipping on multi-client mixes that sum past 0 dBFS while remaining transparent for normal voice levels (allocation-free, lock-free, safe on the realtime audio thread) |
|
|
||||||
| macOS render cadence (producer + ring) | `ios_voice_unit.rs:851-961` runs a 20 ms tokio producer task that calls `AudioHandler::fill_buffer(1920)` and `force_push`es each sample into a `crossbeam ArrayQueue<f32>` (SPSC-effective, MPMC-but-wait-free-per-end); ring capacity 12000 samples ≈ 6.25× pull quantum; 100 ms prebuffer (`PREBUFFER_SAMPLES = 9600` stereo f32) before the VPIO render callback starts draining, matching Mumble's playout margin and WebRTC's kStartDelayMs order of magnitude |
|
|
||||||
| iOS render cadence (direct-fill) | `ios_voice_unit.rs:968-1034` does `AudioHandler::fill_buffer` directly in the VPIO render callback (VPIO on iOS requests 480-frame ≈ 10 ms slices that align with tsclientlib's 20 ms Opus frame); scratch buffer preallocated to 4096×2 f32 at setup time so the realtime callback never `resize()`s; `try_lock` (not `lock`) on the AudioHandler mutex so contention never stalls the realtime IO thread; on `WouldBlock` the callback emits silence and increments `callback_xrun` |
|
|
||||||
| VPIO ducking config (macOS 14+) | `ios_voice_unit.rs` writes an 8-byte `AuVoiceIoOtherAudioDuckingConfiguration` struct (`m_enable_advanced_ducking = 0` disables dynamic voice-activity-driven ducking; `m_ducking_level = kAUVoiceIOOtherAudioDuckingLevelMin = 10`) to selector `kAUVoiceIOProperty_OtherAudioDuckingConfiguration` (= 2108) on the VoiceProcessingIO AudioUnit at startup, minimising the ducking of other apps' audio during a voice session; on macOS 13 the property is silently ignored (VPIO returns the default ducking behaviour) and the code logs a debug message and continues |
|
|
||||||
| Benchmarks | Realtime capture, Opus, and resampler benchmarks provide advisory baseline evidence |
|
| Benchmarks | Realtime capture, Opus, and resampler benchmarks provide advisory baseline evidence |
|
||||||
|
|
||||||
## 6. Storage and Secret Design
|
## 6. Storage and Secret Design
|
||||||
@@ -154,7 +148,6 @@ Design rules:
|
|||||||
| Some module designs are summarized rather than API-by-API | May be insufficient for final process audit | Use this as DV baseline; deepen high-risk modules before final release gate |
|
| Some module designs are summarized rather than API-by-API | May be insufficient for final process audit | Use this as DV baseline; deepen high-risk modules before final release gate |
|
||||||
| Android Keystore-backed DEK is not implemented | Limits storage/security design claims | Controlled by waiver and release-readiness records |
|
| Android Keystore-backed DEK is not implemented | Limits storage/security design claims | Controlled by waiver and release-readiness records |
|
||||||
| Full event replay tooling and live reducer integration evidence are absent | Limits state verification design beyond reducer unit behavior | Controlled as P1 gap and runtime-integration follow-up |
|
| Full event replay tooling and live reducer integration evidence are absent | Limits state verification design beyond reducer unit behavior | Controlled as P1 gap and runtime-integration follow-up |
|
||||||
| Android runtime smoke is blocked when no device/emulator is attached | Android permission/audio/lifecycle paths cannot be claimed from Rust tests alone | Require `adb devices -l` and Android smoke evidence before closing Android verification claims |
|
|
||||||
|
|
||||||
## 13. DV Conclusion
|
## 13. DV Conclusion
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ This index lists the documents required for DV review and identifies their curre
|
|||||||
| Release | `docs/release/release-readiness-go-nogo-record.md` | No-Go for public/store release |
|
| Release | `docs/release/release-readiness-go-nogo-record.md` | No-Go for public/store release |
|
||||||
| Release | `docs/release/platform-release-policy.md` | Baseline candidate |
|
| Release | `docs/release/platform-release-policy.md` | Baseline candidate |
|
||||||
| Governance | `docs/governance/traceability-matrix.md` | DV baseline candidate |
|
| Governance | `docs/governance/traceability-matrix.md` | DV baseline candidate |
|
||||||
| Governance | `docs/governance/maintainability-review-2026-06-08.md` | Working-branch maintainability and fail-safe review |
|
|
||||||
| Security/privacy/legal | `docs/security/security-privacy-legal-guideline.md` | Baseline candidate |
|
| Security/privacy/legal | `docs/security/security-privacy-legal-guideline.md` | Baseline candidate |
|
||||||
| Privacy | `docs/privacy/privacy-policy.md` | Engineering baseline candidate |
|
| Privacy | `docs/privacy/privacy-policy.md` | Engineering baseline candidate |
|
||||||
| Legal | `docs/legal/trademark-and-attribution-review.md` | DEC-012 open |
|
| Legal | `docs/legal/trademark-and-attribution-review.md` | DEC-012 open |
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
# Chanora Maintainability Review — 2026-06-08
|
|
||||||
|
|
||||||
**Document status:** Working-branch review record
|
|
||||||
**Branch:** `simplify-project-review`
|
|
||||||
**Scope:** Project-wide simplification, fail-safe, and verification review
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
|
|
||||||
This record captures the current maintainability review so implementation, verification, and release documents do not drift behind the code. It focuses on unnecessary Modules, shallow Interfaces, duplicate Implementations, built-in replacement opportunities, and fail-safe gaps that need explicit evidence before release claims.
|
|
||||||
|
|
||||||
## 2. Changes Already Applied on the Branch
|
|
||||||
|
|
||||||
| Area | Files | Maintainability result |
|
|
||||||
|---|---|---|
|
|
||||||
| Core event DTO locality | `core/chanora_core/src/events.rs`, `core/chanora_core/src/lib.rs` | Public Core event DTOs moved out of the oversized Core integration Module while preserving the public `chanora_core::*` Interface through re-exports. |
|
|
||||||
| Core network diagnostics locality | `core/chanora_core/src/network_diagnostics.rs`, `core/chanora_core/src/lib.rs` | Private network diagnostic ring-buffer state and its regression test now live next to the Implementation they protect. |
|
|
||||||
| Bounded queues | `core/chanora_core/src/network_diagnostics.rs`, `crates/chanora_diagnostics/src/lib.rs` | Replaced `Vec + remove(0)` queue behaviour with `VecDeque`, reducing custom queue code and avoiding O(n) front removal. |
|
|
||||||
| PTT backend errors | `crates/chanora_audio/src/ptt_backends/mod.rs` | Replaced manual `Display` / `Error` Implementation with existing `thiserror::Error`; regression test keeps user-facing strings stable. |
|
|
||||||
| Render downmix | `crates/chanora_audio/src/voice_render.rs`, `crates/chanora_audio/src/ios_raw_unit.rs` | Removed duplicate mono-i16 downmix loop by using the interleaved helper with one output channel. |
|
|
||||||
| State reducer | `crates/chanora_state/src/lib.rs` | Reused the main snapshot reducer for reconnect snapshots instead of duplicating normalization and delta construction. |
|
|
||||||
| Workspace metadata | `crates/chanora_resolver/Cargo.toml`, `Cargo.lock` | Resolver inherits workspace package metadata, improving release metadata Locality. |
|
|
||||||
| Flutter voice fail-safes | `apps/chanora_flutter/lib/main.dart`, `apps/chanora_flutter/lib/widgets/voice_compact.dart`, `apps/chanora_flutter/lib/services/ios_audio_session_controller.dart` | Commit `d835394` preserves independent mute owners, releases touch PTT on disposal while held, and catches iOS audio-session `MissingPluginException` / activation failures so they do not become unhandled async errors. |
|
|
||||||
| Rust realtime callback hardening | `crates/chanora_audio/src/android_voice_unit.rs`, `crates/chanora_audio/src/ios_raw_unit.rs`, `crates/chanora_audio/src/engine.rs` | Commit `8606eb4` hardens Android/iOS realtime callback paths. The current branch also migrates the Android-only JNI paths to `jni-rs` 0.22 so the supported ARM64 Android debug build compiles. Full lock-free audio-handler/config/debug-recorder redesign remains follow-up work. |
|
|
||||||
|
|
||||||
## 3. Remaining Simplification Opportunities
|
|
||||||
|
|
||||||
| Recommendation | Candidate files | Strength | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Continue splitting Core internals by responsibility | `core/chanora_core/src/lib.rs` | Strong | Next slices should be reconnect/session, voice projection, storage helpers, and diagnostics export. Keep public re-exports stable. |
|
|
||||||
| Make Bridge depend on Core rather than Audio where possible | `crates/chanora_bridge/Cargo.toml`, `crates/chanora_bridge/src/api.rs`, `core/chanora_core/src/lib.rs` | Worth exploring | The Bridge currently has a direct audio edge. Apply the deletion test before removing it. |
|
|
||||||
| Decide whether prefetch deserves a crate-level Seam | `crates/chanora_prefetch/src/lib.rs`, `crates/chanora_resolver/src/lib.rs`, `core/chanora_core/src/lib.rs` | Worth exploring | Prefetch is a small TTL cache and fire-and-forget resolver Adapter. Merge into resolver if it is resolver policy; merge into Core if it is app orchestration policy. |
|
|
||||||
| Consolidate protocol/core/bridge event catalogues | `crates/chanora_protocol/src/dto.rs`, `core/chanora_core/src/events.rs`, `crates/chanora_bridge/src/api.rs` | Worth exploring | Protocol-owned deltas and Core-owned lifecycle events are currently mirrored through multiple DTO layers. |
|
|
||||||
| Reduce bridge DTO mirror boilerplate | `crates/chanora_bridge/src/api.rs` | Worth exploring | Verify Flutter Rust Bridge support before deleting mirrors. If mirrors remain required, centralize conversion patterns and keep field order aligned with Core DTOs. |
|
|
||||||
| Clarify protocol voice packet exception | `crates/chanora_protocol/src/lib.rs`, `crates/chanora_audio/Cargo.toml` | Worth exploring | The protocol crate documents `tsclientlib` isolation but deliberately re-exports voice packet types for audio. Document this as an explicit voice wire Seam or move packet construction fully into protocol. |
|
|
||||||
| Remove shallow audio helpers only after public API check | `crates/chanora_audio/src/processor/noop.rs`, `crates/chanora_audio/src/processor/platform.rs`, `crates/chanora_audio/src/frame.rs` | Speculative | These Modules are shallow, but deletion must wait until external/public API expectations are checked. |
|
|
||||||
| Redesign remaining audio shared state outside realtime callbacks | `crates/chanora_audio/src/engine.rs`, platform voice units, debug recorder/config paths | Strong follow-up | The focused callback hardening is complete, but a full lock-free `AudioHandler` / config / debug-recorder redesign should be planned separately and verified on device. |
|
|
||||||
| Review protocol/core disconnect and control-plane bounds | `core/chanora_core/src/lib.rs`, `crates/chanora_protocol/src/adapter.rs` | Strong follow-up | Unless closed by a later code slice, sustained voice traffic and broken transport should be reviewed for bounded control request and disconnect progress. |
|
|
||||||
|
|
||||||
## 4. Fail-Safe Gaps That Need Evidence
|
|
||||||
|
|
||||||
| Gap | Risk | Required evidence before release claim |
|
|
||||||
|---|---|---|
|
|
||||||
| Android Keystore-backed DEK remains deferred | Android identity/bookmark encryption has weaker fail-safe properties than final target secure-storage design. | Android secure-storage audit or waiver; explicit release-readiness limitation. |
|
|
||||||
| Android permission/audio lifecycle needs deeper route exercise | Build/install/launch smoke now passes on the emulator, but full permission-flow and audio-route lifecycle behavior still need an interactive scenario or device test before release. | Device/emulator scenario covering permission request/denial/grant, voice controls, foreground service, audio focus, and route/SCO transitions. |
|
|
||||||
| iOS device runtime verification not executed in this review | The iOS audio-session error path is hardened, but VoiceProcessingIO/session ordering and runtime audio behavior still need device evidence. | iOS device or simulator build/run plus audio-session smoke evidence before iOS runtime success is claimed. |
|
|
||||||
| VAD / VoiceActivity wording drift | VAD assets, tests, and scaffolding exist, but product-enabled `VoiceActivity` remains reserved/disabled per DEC-030. | Release, README, and verification wording must distinguish scaffolding/assets/tests from shipped product behavior. |
|
|
||||||
| Protocol voice packet re-export is an intentional exception | Future maintainers may assume complete protocol isolation and accidentally widen the Seam. | Architecture note in SAD/SDD or a decision-register entry. |
|
|
||||||
| Bridge DTO mirror drift | Field additions can be missed across Core, Bridge, and Dart generated DTOs. | Bridge generation check plus Flutter analyze/test after bridge DTO changes. |
|
|
||||||
| Full live reducer integration remains separate from reducer unit coverage | State reducer tests are strong, but runtime UI still has snapshot/probe paths. | SWE.5 integration run proving live protocol events fold through the intended state path, or explicit P1 deferral. |
|
|
||||||
| Event replay tooling remains absent | Replay-based diagnosis and regression reproduction are limited. | Event replay tool implementation or waiver. |
|
|
||||||
|
|
||||||
## 5. Verification Policy for Future Code Changes
|
|
||||||
|
|
||||||
| Change type | Required verification |
|
|
||||||
|---|---|
|
|
||||||
| Rust-only change | `cargo fmt --all`, `cargo check --workspace`, `cargo test --workspace` |
|
|
||||||
| Bridge DTO/API change | Rust verification plus bridge generation check, `flutter analyze`, and `flutter test --exclude-tags e2e` in `apps/chanora_flutter` |
|
|
||||||
| Android platform/audio/permission change | Rust/Flutter verification plus NDK target compilation, `adb devices -l`, Android build/install, and a device or emulator smoke test |
|
|
||||||
| Documentation-only change | Read affected docs and ensure cross-links/document index stay current; code tests are not required unless docs describe a code change just made |
|
|
||||||
|
|
||||||
## 6. Android ADB Status for This Review
|
|
||||||
|
|
||||||
`adb devices -l` now reports an authorized emulator target:
|
|
||||||
|
|
||||||
```text
|
|
||||||
emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1
|
|
||||||
```
|
|
||||||
|
|
||||||
Android default debug build still fails because SDD-118 excludes `armeabi-v7a`; use a supported ABI target. ARM64 debug build/install/launch smoke evidence from 2026-06-08:
|
|
||||||
|
|
||||||
```text
|
|
||||||
flutter build apk --debug --target-platform android-arm64
|
|
||||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
|
||||||
|
|
||||||
adb -s emulator-5554 install -r apps/chanora_flutter/build/app/outputs/flutter-apk/app-debug.apk
|
|
||||||
Success
|
|
||||||
|
|
||||||
adb -s emulator-5554 shell am start -W -n app.chanora.chanora_flutter/.MainActivity
|
|
||||||
Status: ok
|
|
||||||
LaunchState: COLD
|
|
||||||
Activity: app.chanora.chanora_flutter/.MainActivity
|
|
||||||
TotalTime: 6514
|
|
||||||
|
|
||||||
adb -s emulator-5554 shell pidof app.chanora.chanora_flutter
|
|
||||||
7287
|
|
||||||
```
|
|
||||||
|
|
||||||
`dumpsys window app.chanora.chanora_flutter` showed `MainActivity` visible with `isReadyForDisplay()=true`, and `dumpsys activity top` showed `app.chanora.chanora_flutter/.MainActivity` resumed with window focus. This is build/install/launch smoke evidence only; permission-flow success and audio-lifecycle success are not claimed by this review.
|
|
||||||
|
|
||||||
## 7. Release and Documentation Alignment Notes
|
|
||||||
|
|
||||||
- Android minimum runtime baseline is API 28 (Android 9.0) per SysRS-288, SRS-187, DEC-004, and the Gradle `minSdk = 28` configuration. Documents must not revive the older API 24 baseline.
|
|
||||||
- Flutter app version/build is `0.3.0+100` in `apps/chanora_flutter/pubspec.yaml`. Rust workspace package version remains `0.2.0-beta.1`. Release documents must distinguish these values instead of treating them as one candidate version.
|
|
||||||
- The v0.3.0 changelog entry may mention VAD assets/backends only as implementation scaffolding; product-enabled `VoiceActivity` remains disabled/coming-soon until DEC-030 is superseded and runtime verification exists.
|
|
||||||
- README wording must describe the existing Flutter/Rust workspace and app scaffold, not a future scaffold that has not been created.
|
|
||||||
|
|
||||||
## 8. Git Policy
|
|
||||||
|
|
||||||
No commit is created automatically. Commit only on explicit user demand, after reviewing `git status`, `git diff`, and recent log output.
|
|
||||||
@@ -11,14 +11,11 @@ This register records product and engineering decisions referenced by the DV doc
|
|||||||
|
|
||||||
| Decision | State | DV impact |
|
| Decision | State | DV impact |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| DEC-004 Android minimum runtime API 28 | Accepted by requirements baseline | Android release, verification, and README wording must use API 28 rather than the earlier API 24 recommendation |
|
|
||||||
| DEC-012 legal/trademark/OSS review | Open | Blocks public/store release |
|
| DEC-012 legal/trademark/OSS review | Open | Blocks public/store release |
|
||||||
| DEC-020 dual license MIT OR Apache-2.0 | Accepted per README | Supports license posture; dependency notices still require review |
|
| DEC-020 dual license MIT OR Apache-2.0 | Accepted per README | Supports license posture; dependency notices still require review |
|
||||||
| DEC-027 desktop mouse side-button PTT | Accepted by requirements baseline | Verification must not over-claim unsupported platform input classes |
|
| DEC-027 desktop mouse side-button PTT | Accepted by requirements baseline | Verification must not over-claim unsupported platform input classes |
|
||||||
| DEC-030 VAD deferral | Accepted as deferral | VAD scaffolding, assets, and tests may exist, but product `VoiceActivity` remains disabled/coming-soon until a later baseline enables and verifies it |
|
| DEC-030 VAD deferral | Accepted as deferral | `VoiceActivity` remains disabled/coming-soon |
|
||||||
| DEC-032 Android CMake patch exit path | Active tracking | Patched dependency requires reevaluation |
|
| DEC-032 Android CMake patch exit path | Active tracking | Patched dependency requires reevaluation |
|
||||||
| DEC-033 macOS VPIO ducking configuration | Accepted | Write `kAUVoiceIOProperty_OtherAudioDuckingConfiguration` with `mEnableAdvancedDucking=0` (disables dynamic voice-activity-driven ducking) and `mDuckingLevel=Min` (= 10) to minimise the ducking of other apps' audio during a voice session; property is macOS 14+ only, the macOS 13 set fails silently (debug log) and VPIO uses its default behaviour; matches the iOS `.voiceChat` baseline on macOS 14+ |
|
|
||||||
| DEC-034 Android runtime verification gate | Active tracking | Android target compilation, install, and runtime smoke are blocked locally until `aarch64-linux-android-clang` is available and `adb devices -l` shows an authorized target; release docs must not claim Android runtime success |
|
|
||||||
|
|
||||||
## 3. DV Rule
|
## 3. DV Rule
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
# Chanora Implementation Status — 2026-05-28
|
# Chanora Implementation Status — 2026-05-28
|
||||||
|
|
||||||
**Workspace version:** `v0.2.0-beta.1`
|
**Workspace version:** `v0.2.0-beta.1`
|
||||||
**Flutter app version/build:** `0.3.0+100`
|
**CHANGELOG latest:** `v1.0.0-rc.1`
|
||||||
**CHANGELOG latest:** `v0.3.0`
|
**Build status:** All 9 crates compile cleanly.
|
||||||
**Build status:** Host Rust workspace evidence shows all 9 crates compile cleanly. This does not claim Android target success; Android target compile/install/smoke evidence remains blocked locally as noted below.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,9 +18,9 @@
|
|||||||
| Localization (en + zh-Hans) | `l10n/generated/app_localizations_en.dart` + `app_localizations_zh.dart`, `l10n.yaml` |
|
| Localization (en + zh-Hans) | `l10n/generated/app_localizations_en.dart` + `app_localizations_zh.dart`, `l10n.yaml` |
|
||||||
| Flutter/Rust bridge | `chanora_bridge` crate (2152-line `api.rs`), generated `frb_generated.rs`, Dart side generated |
|
| Flutter/Rust bridge | `chanora_bridge` crate (2152-line `api.rs`), generated `frb_generated.rs`, Dart side generated |
|
||||||
| Protocol adapter | `chanora_protocol` — `tsclientlib` isolated behind `ProtocolClient`, typed DTOs, `ProtocolError` catalogue |
|
| Protocol adapter | `chanora_protocol` — `tsclientlib` isolated behind `ProtocolClient`, typed DTOs, `ProtocolError` catalogue |
|
||||||
| Connection lifecycle | `chanora_core` — supervisor task, exponential backoff reconnect (1s→60s), user-disconnect suppresses reconnect; branch `simplify-project-review` has started splitting the previous large `lib.rs` into focused internal Modules (`events.rs`, `network_diagnostics.rs`) while preserving public re-exports |
|
| Connection lifecycle | `chanora_core` (2682-line `lib.rs`) — supervisor task, exponential backoff reconnect (1s→60s), user-disconnect suppresses reconnect |
|
||||||
| State sync reducer unit | `chanora_state` — `ConnectionState`, `channel_join`, snapshot/delta reducers, reconnect handling, deterministic ordering, malformed duplicate normalization, channel-delete/client cleanup, and reducer unit tests. Runtime core integration still uses snapshot/probe refresh paths and remains separate validation work. |
|
| State sync reducer unit | `chanora_state` — `ConnectionState`, `channel_join`, snapshot/delta reducers, reconnect handling, deterministic ordering, malformed duplicate normalization, channel-delete/client cleanup, and reducer unit tests. Runtime core integration still uses snapshot/probe refresh paths and remains separate validation work. |
|
||||||
| Audio subsystem | `chanora_audio` — Opus encode/decode, HPF/NS/AEC3/AGC2 DSP, PTT backends (Windows/macOS/Linux/focused), iOS VoiceProcessingIO, Android Oboe, jitter buffer via `tsclientlib::audio::AudioHandler`, mixer, mute/deaf gates, release-tail timer, and VAD scaffolding/assets. Product `VoiceActivity` remains disabled per DEC-030. |
|
| Audio subsystem | `chanora_audio` — Opus encode/decode, HPF/NS/AEC3/AGC2 DSP, PTT backends (Windows/macOS/Linux/focused), iOS VoiceProcessingIO, Android Oboe, jitter buffer via `tsclientlib::audio::AudioHandler`, mixer, mute/deaf gates, release-tail timer, VAD |
|
||||||
| Push-to-talk | Per-platform backends: Windows Raw Input + hook fallback, macOS Event Tap, Linux freedesktop portal, focused fallback; `PttCapabilityLevel` (L0–L3); missed-key-up watchdog |
|
| Push-to-talk | Per-platform backends: Windows Raw Input + hook fallback, macOS Event Tap, Linux freedesktop portal, focused fallback; `PttCapabilityLevel` (L0–L3); missed-key-up watchdog |
|
||||||
| Voice controls UI | `voice_bar`, `voice_compact`, `voice_haptics`, `voice_level_meter`, `voice_platform`, `ptt_capability_badge`, `talk_power_warning` |
|
| Voice controls UI | `voice_bar`, `voice_compact`, `voice_haptics`, `voice_level_meter`, `voice_platform`, `ptt_capability_badge`, `talk_power_warning` |
|
||||||
| Storage (non-secret) | `chanora_storage` — `BookmarkRepository` (SQLite/rusqlite bundled, schema v2), ChaCha20-Poly1305 encrypted passwords |
|
| Storage (non-secret) | `chanora_storage` — `BookmarkRepository` (SQLite/rusqlite bundled, schema v2), ChaCha20-Poly1305 encrypted passwords |
|
||||||
@@ -46,7 +45,7 @@
|
|||||||
| Link trust | `link_trust_service.dart` |
|
| Link trust | `link_trust_service.dart` |
|
||||||
| About dialog | Non-affiliation statement, dual-license declaration, NOTICE pointer |
|
| About dialog | Non-affiliation statement, dual-license declaration, NOTICE pointer |
|
||||||
| CI | GitHub Actions on every push |
|
| CI | GitHub Actions on every push |
|
||||||
| Workspace compiles | Host Rust workspace evidence shows all 9 crates build cleanly; Android target compilation remains blocked locally as noted below. |
|
| Workspace compiles | All 9 crates build cleanly |
|
||||||
|
|
||||||
### Partial / Scaffold Only
|
### Partial / Scaffold Only
|
||||||
|
|
||||||
@@ -55,18 +54,17 @@
|
|||||||
| Event replay tooling | Reducer tests cover the state-sync contract, but standalone replay-file tooling remains a P1 verification gap. |
|
| Event replay tooling | Reducer tests cover the state-sync contract, but standalone replay-file tooling remains a P1 verification gap. |
|
||||||
| Reducer runtime integration evidence | The standalone reducer is unit-tested, but `chanora_core` still refreshes UI state through snapshot/probe paths rather than folding all live protocol events through `chanora_state::reduce`. |
|
| Reducer runtime integration evidence | The standalone reducer is unit-tested, but `chanora_core` still refreshes UI state through snapshot/probe paths rather than folding all live protocol events through `chanora_state::reduce`. |
|
||||||
| Silero VAD | `assets/models/silero_vad.onnx` bundled but DEC-030 defers VAD to P1; `TransmitMode::VoiceActivity` is reserved and disabled in this baseline. |
|
| Silero VAD | `assets/models/silero_vad.onnx` bundled but DEC-030 defers VAD to P1; `TransmitMode::VoiceActivity` is reserved and disabled in this baseline. |
|
||||||
| macOS build | Source-buildable only; no public release artifact is approved. |
|
| macOS build | Not in `v1.0.0-rc.1` release artifacts (source-buildable only per `staged-release-plan.md`). |
|
||||||
| Windows build | Source-buildable only; no public release artifact is approved. |
|
| Windows build | Same — source-buildable, not in rc.1 release artifacts. |
|
||||||
| iOS build | Source-buildable/unsigned validation only; no TestFlight/App Store release artifact is approved. |
|
| iOS build | Same — source-buildable, not in rc.1 release artifacts. |
|
||||||
|
|
||||||
### Not Done (P0 blockers remaining)
|
### Not Done (P0 blockers remaining)
|
||||||
|
|
||||||
| Item | Status |
|
| Item | Status |
|
||||||
|---|---|
|
|---|---|
|
||||||
| DEC-012 legal/trademark/OSS review | Explicitly open. Public release is blocked. |
|
| DEC-012 legal/trademark/OSS review | Explicitly open — `v1.0.0-rc.1` is the candidate awaiting sign-off. Public release is blocked. |
|
||||||
| Android Keystore-backed DEK | Deferred to v1.1. Android still uses file-fallback for the Data Encryption Key. |
|
| Android Keystore-backed DEK | Deferred to v1.1. Android still uses file-fallback for the Data Encryption Key. |
|
||||||
| Android target compile/install/smoke evidence | Blocked locally until the Android NDK compiler `aarch64-linux-android-clang` is available and `adb devices -l` shows an authorized device or emulator. |
|
| iOS `AVAudioSession.Mode.voiceChat` | Implemented in `apps/chanora_flutter/ios/Runner/AppDelegate.swift`; release readiness still requires device audio validation and candidate evidence attachment. |
|
||||||
| iOS `AVAudioSession.Mode.voiceChat` | Implemented in `apps/chanora_flutter/ios/Runner/AppDelegate.swift` with call-scoped activation (idle `.ambient` baseline; VoIP `.playAndRecord` + `.voiceChat` + `.mixWithOthers` engaged only on `BridgeEvent::AudioStarted` via `chanora/ios_audio_session` MethodChannel). Release readiness still requires device audio validation and candidate evidence attachment. |
|
|
||||||
| Candidate state-sync evidence attachment | Reducer tests exist and pass locally; release readiness still needs candidate CI/run IDs and runtime integration evidence attached before public release approval. |
|
| Candidate state-sync evidence attachment | Reducer tests exist and pass locally; release readiness still needs candidate CI/run IDs and runtime integration evidence attached before public release approval. |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -94,7 +92,7 @@
|
|||||||
| Recent servers persistence (SRS-085) | Not confirmed in storage crate |
|
| Recent servers persistence (SRS-085) | Not confirmed in storage crate |
|
||||||
| UI settings persistence (SRS-087) | Implemented for current P1 scope using `shared_preferences`: host, nickname, permission explanation flag, and theme mode (`system` / `light` / `dark`). SQLite-backed UI settings remain a future hardening option if multi-profile or transactional settings are introduced. |
|
| UI settings persistence (SRS-087) | Implemented for current P1 scope using `shared_preferences`: host, nickname, permission explanation flag, and theme mode (`system` / `light` / `dark`). SQLite-backed UI settings remain a future hardening option if multi-profile or transactional settings are introduced. |
|
||||||
| Event replay tool (SRS-061, SRS-098) | No replay infrastructure found |
|
| Event replay tool (SRS-061, SRS-098) | No replay infrastructure found |
|
||||||
| Network diagnostics (SRS-100) | Core tracks connect/disconnect counts and last-loss reasons in `network_diagnostics.rs`; export/integration evidence still needs release-candidate attachment |
|
| Network diagnostics (SRS-100) | Not found in diagnostics export |
|
||||||
| Side navigation rail for medium layout (SRS-153) | Not confirmed |
|
| Side navigation rail for medium layout (SRS-153) | Not confirmed |
|
||||||
| Keyboard focus traversal (SRS-160) | Not confirmed |
|
| Keyboard focus traversal (SRS-160) | Not confirmed |
|
||||||
| Android audio focus / BT route changes (SRS-112) | Partial — `MODE_IN_COMMUNICATION` done; full focus/BT handling not confirmed |
|
| Android audio focus / BT route changes (SRS-112) | Partial — `MODE_IN_COMMUNICATION` done; full focus/BT handling not confirmed |
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ A waiver records a known gap that reviewers may accept for a limited decision sc
|
|||||||
| DV-WVR-006 | Desktop and iOS artifacts are source-buildable or unsigned only | `docs/implementation-status-2026-05-28.md`, `docs/release/ios-build.md` | Blocks packaged public release claims | Internal validation from source/unsigned builds only | Signed/notarized/package artifacts exist and hashes are recorded |
|
| DV-WVR-006 | Desktop and iOS artifacts are source-buildable or unsigned only | `docs/implementation-status-2026-05-28.md`, `docs/release/ios-build.md` | Blocks packaged public release claims | Internal validation from source/unsigned builds only | Signed/notarized/package artifacts exist and hashes are recorded |
|
||||||
| DV-WVR-007 | Silero VAD asset bundled while `VoiceActivity` is deferred | `docs/implementation-status-2026-05-28.md` | Risk that UI/release wording overstates VAD availability | DV may pass if VoiceActivity remains disabled/coming-soon | VAD implementation allocated in a later baseline or asset/wording reconciled |
|
| DV-WVR-007 | Silero VAD asset bundled while `VoiceActivity` is deferred | `docs/implementation-status-2026-05-28.md` | Risk that UI/release wording overstates VAD availability | DV may pass if VoiceActivity remains disabled/coming-soon | VAD implementation allocated in a later baseline or asset/wording reconciled |
|
||||||
| DV-WVR-008 | Artifact hashes, tag, and candidate run IDs are not recorded in release record | `docs/release/release-readiness-go-nogo-record.md` | Blocks final release approval and reproducibility | DV documentation review only | Candidate build run records, tag, commit SHA, and artifact hashes are recorded |
|
| DV-WVR-008 | Artifact hashes, tag, and candidate run IDs are not recorded in release record | `docs/release/release-readiness-go-nogo-record.md` | Blocks final release approval and reproducibility | DV documentation review only | Candidate build run records, tag, commit SHA, and artifact hashes are recorded |
|
||||||
| DV-WVR-009 | Android target compile and runtime smoke blocked locally | `docs/governance/maintainability-review-2026-06-08.md`, `docs/release/release-readiness-go-nogo-record.md` | Blocks Android runtime, permission-flow, and audio-lifecycle success claims | Documentation review only; internal validation must keep Android limitation stated | Android NDK compiler `aarch64-linux-android-clang` is available, `adb devices -l` shows an authorized target, and Android build/install/smoke evidence is attached |
|
|
||||||
|
|
||||||
## 3. Waiver Review Rules
|
## 3. Waiver Review Rules
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Document status:** DV meeting baseline candidate
|
**Document status:** DV meeting baseline candidate
|
||||||
**Date:** 2026-05-29
|
**Date:** 2026-05-29
|
||||||
**Candidate:** documentation/DV evidence over Rust workspace version `0.2.0-beta.1`; Flutter app version/build `0.3.0+100`
|
**Candidate:** `v1.0.0-rc.1` evidence over workspace version `0.2.0-beta.1`
|
||||||
**Decision:** No-Go for public/store release; Conditional Go only for documentation review and continued internal DV validation
|
**Decision:** No-Go for public/store release; Conditional Go only for documentation review and continued internal DV validation
|
||||||
|
|
||||||
## 1. Decision Summary
|
## 1. Decision Summary
|
||||||
@@ -23,11 +23,10 @@ The phrase `Conditional Go` in this record is restricted to document-baseline re
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Workspace version | `0.2.0-beta.1` |
|
| Workspace version | `0.2.0-beta.1` |
|
||||||
| Flutter app version/build | `0.3.0+100` from `apps/chanora_flutter/pubspec.yaml` |
|
| CHANGELOG latest candidate | `v1.0.0-rc.1` |
|
||||||
| CHANGELOG latest candidate | `v0.3.0` baseline entry; public release version not reached |
|
| Build number | `76` from `apps/chanora_flutter/pubspec.yaml` |
|
||||||
| Build number | `100` from `apps/chanora_flutter/pubspec.yaml` |
|
|
||||||
| Commit SHA | To be recorded from the candidate build job before release approval |
|
| Commit SHA | To be recorded from the candidate build job before release approval |
|
||||||
| Git tag | To be recorded if a candidate is tagged for release validation |
|
| Git tag | To be recorded if `v1.0.0-rc.1` is tagged for release validation |
|
||||||
| Artifact hashes | Not recorded in current workspace; required before release approval |
|
| Artifact hashes | Not recorded in current workspace; required before release approval |
|
||||||
| Release owner | Product / Release Operations |
|
| Release owner | Product / Release Operations |
|
||||||
| Verification owner | Software QA with System Engineering support |
|
| Verification owner | Software QA with System Engineering support |
|
||||||
@@ -55,7 +54,6 @@ Current implementation status is summarized in `docs/implementation-status-2026-
|
|||||||
| Privacy policy baseline | Baseline candidate | Requires owner/legal review before public/store release |
|
| Privacy policy baseline | Baseline candidate | Requires owner/legal review before public/store release |
|
||||||
| Security/privacy evidence | Partial | Blocks strong secure-storage and diagnostic claims until audits attach evidence |
|
| Security/privacy evidence | Partial | Blocks strong secure-storage and diagnostic claims until audits attach evidence |
|
||||||
| Android secure-storage DEK | Deferred to v1.1 | Requires waiver for internal testing; limits release claim |
|
| Android secure-storage DEK | Deferred to v1.1 | Requires waiver for internal testing; limits release claim |
|
||||||
| Android target compile/install/smoke | Blocked locally | Missing Android NDK compiler `aarch64-linux-android-clang` and no authorized ADB target block Android runtime claims |
|
|
||||||
| iOS release build/signing | Unsigned verification only | Blocks TestFlight/App Store release |
|
| iOS release build/signing | Unsigned verification only | Blocks TestFlight/App Store release |
|
||||||
| macOS signing/notarization | Not complete | Blocks macOS public binary release |
|
| macOS signing/notarization | Not complete | Blocks macOS public binary release |
|
||||||
| Windows/Linux packaging | Source-buildable only for candidate | Blocks packaged public desktop release claims |
|
| Windows/Linux packaging | Source-buildable only for candidate | Blocks packaged public desktop release claims |
|
||||||
@@ -72,7 +70,6 @@ Current implementation status is summarized in `docs/implementation-status-2026-
|
|||||||
| iOS unsigned build | CI defined | Attach latest passing candidate run; add signing evidence before release |
|
| iOS unsigned build | CI defined | Attach latest passing candidate run; add signing evidence before release |
|
||||||
| Compatible-server demo | Evidence not attached in this record | Run and attach demo notes/logs |
|
| Compatible-server demo | Evidence not attached in this record | Run and attach demo notes/logs |
|
||||||
| Audio send/receive and processing demo | Evidence not attached in this record | Run and attach platform evidence |
|
| Audio send/receive and processing demo | Evidence not attached in this record | Run and attach platform evidence |
|
||||||
| Android target build/install/smoke | Blocked locally | Install/fix Android NDK compiler, connect/authorize a device or emulator, then attach build/install/smoke evidence |
|
|
||||||
| Diagnostics redaction/export demo | Evidence not attached in this record | Run and attach export review |
|
| Diagnostics redaction/export demo | Evidence not attached in this record | Run and attach export review |
|
||||||
| Platform secure-storage audit | Partial | Attach per-platform audit or waiver |
|
| Platform secure-storage audit | Partial | Attach per-platform audit or waiver |
|
||||||
| PTT capability evidence | Partial | Attach per-platform `PttCapabilityLevel` and backend record |
|
| PTT capability evidence | Partial | Attach per-platform `PttCapabilityLevel` and backend record |
|
||||||
@@ -81,7 +78,7 @@ Current implementation status is summarized in `docs/implementation-status-2026-
|
|||||||
|
|
||||||
| Platform | Current readiness | Release decision |
|
| Platform | Current readiness | Release decision |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Android | Core platform implementation present; Android Keystore-backed DEK deferred; local target compile/install/smoke blocked by missing `aarch64-linux-android-clang` and no authorized ADB target | Conditional internal validation only after Android build/install/smoke evidence or explicit waiver |
|
| Android | Core platform implementation present; Android Keystore-backed DEK deferred | Conditional internal validation only |
|
||||||
| iOS | Unsigned build path present; signing and store pipeline incomplete | No-Go for store release |
|
| iOS | Unsigned build path present; signing and store pipeline incomplete | No-Go for store release |
|
||||||
| Windows | Source-buildable; smoke procedure exists | No-Go for packaged release until smoke/signing evidence exists |
|
| Windows | Source-buildable; smoke procedure exists | No-Go for packaged release until smoke/signing evidence exists |
|
||||||
| macOS | Source-buildable; public artifact not in candidate | No-Go for packaged release until signing/notarization evidence exists |
|
| macOS | Source-buildable; public artifact not in candidate | No-Go for packaged release until signing/notarization evidence exists |
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ those terms.
|
|||||||
|
|
||||||
| License | Crate count |
|
| License | Crate count |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `Apache License 2.0` | 327 |
|
| `Apache License 2.0` | 333 |
|
||||||
| `MIT License` | 74 |
|
| `MIT License` | 74 |
|
||||||
| `Unicode License v3` | 19 |
|
| `Unicode License v3` | 19 |
|
||||||
| `BSD 3-Clause "New" or "Revised" License` | 14 |
|
| `BSD 3-Clause "New" or "Revised" License` | 14 |
|
||||||
@@ -40,6 +40,7 @@ those terms.
|
|||||||
| zeroize | 1.8.2 | `Apache License 2.0` | <https://github.com/RustCrypto/utils> |
|
| zeroize | 1.8.2 | `Apache License 2.0` | <https://github.com/RustCrypto/utils> |
|
||||||
| zeroize_derive | 1.4.3 | `Apache License 2.0` | <https://github.com/RustCrypto/utils/tree/master/zeroize/derive> |
|
| zeroize_derive | 1.4.3 | `Apache License 2.0` | <https://github.com/RustCrypto/utils/tree/master/zeroize/derive> |
|
||||||
| windows-collections | 0.3.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-collections | 0.3.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
|
| windows-core | 0.54.0 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows-core | 0.62.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-core | 0.62.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows-future | 0.3.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-future | 0.3.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows-implement | 0.60.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-implement | 0.60.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
@@ -47,23 +48,28 @@ those terms.
|
|||||||
| windows-link | 0.2.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-link | 0.2.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows-numerics | 0.3.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-numerics | 0.3.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows-registry | 0.6.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-registry | 0.6.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
|
| windows-result | 0.1.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows-result | 0.4.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-result | 0.4.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows-strings | 0.5.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-strings | 0.5.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows-sys | 0.45.0 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-sys | 0.45.0 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows-sys | 0.60.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-sys | 0.60.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows-sys | 0.61.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-sys | 0.61.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows-targets | 0.42.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-targets | 0.42.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
|
| windows-targets | 0.52.6 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows-targets | 0.53.5 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-targets | 0.53.5 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows-threading | 0.2.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows-threading | 0.2.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
|
| windows | 0.54.0 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows | 0.62.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows | 0.62.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
|
| windows_x86_64_gnu | 0.52.6 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows_x86_64_gnu | 0.53.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows_x86_64_gnu | 0.53.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows_x86_64_msvc | 0.42.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows_x86_64_msvc | 0.42.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
|
| windows_x86_64_msvc | 0.52.6 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| windows_x86_64_msvc | 0.53.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
| windows_x86_64_msvc | 0.53.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
|
||||||
| moka | 0.12.15 | `Apache License 2.0` | <https://github.com/moka-rs/moka> |
|
| moka | 0.12.15 | `Apache License 2.0` | <https://github.com/moka-rs/moka> |
|
||||||
| tinyvec_macros | 0.1.1 | `Apache License 2.0` | <https://github.com/Soveu/tinyvec_macros> |
|
| tinyvec_macros | 0.1.1 | `Apache License 2.0` | <https://github.com/Soveu/tinyvec_macros> |
|
||||||
| zerocopy | 0.8.50 | `Apache License 2.0` | <https://github.com/google/zerocopy> |
|
| zerocopy | 0.8.50 | `Apache License 2.0` | <https://github.com/google/zerocopy> |
|
||||||
| allo-isolate | 0.1.27 | `Apache License 2.0` | <https://github.com/sunshine-protocol/allo-isolate> |
|
| allo-isolate | 0.1.27 | `Apache License 2.0` | <https://github.com/sunshine-protocol/allo-isolate> |
|
||||||
| cpal | 0.18.0 | `Apache License 2.0` | <https://github.com/RustAudio/cpal> |
|
| cpal | 0.17.3 | `Apache License 2.0` | <https://github.com/RustAudio/cpal> |
|
||||||
| rustls-platform-verifier | 0.7.0 | `Apache License 2.0` | <https://github.com/rustls/rustls-platform-verifier> |
|
| rustls-platform-verifier | 0.7.0 | `Apache License 2.0` | <https://github.com/rustls/rustls-platform-verifier> |
|
||||||
| self_cell | 1.2.2 | `Apache License 2.0` | <https://github.com/Voultapher/self_cell> |
|
| self_cell | 1.2.2 | `Apache License 2.0` | <https://github.com/Voultapher/self_cell> |
|
||||||
| tracing-android | 0.2.0 | `Apache License 2.0` | <https://github.com/Actyx/tracing-android> |
|
| tracing-android | 0.2.0 | `Apache License 2.0` | <https://github.com/Actyx/tracing-android> |
|
||||||
@@ -309,7 +315,7 @@ those terms.
|
|||||||
| jni | 0.22.4 | `Apache License 2.0` | <https://github.com/jni-rs/jni-rs> |
|
| jni | 0.22.4 | `Apache License 2.0` | <https://github.com/jni-rs/jni-rs> |
|
||||||
| libc | 0.2.186 | `Apache License 2.0` | <https://github.com/rust-lang/libc> |
|
| libc | 0.2.186 | `Apache License 2.0` | <https://github.com/rust-lang/libc> |
|
||||||
| linux-keyutils | 0.2.5 | `Apache License 2.0` | <https://github.com/landhb/linux-keyutils> |
|
| linux-keyutils | 0.2.5 | `Apache License 2.0` | <https://github.com/landhb/linux-keyutils> |
|
||||||
| mach2 | 0.6.0 | `Apache License 2.0` | <https://github.com/JohnTitor/mach2> |
|
| mach2 | 0.5.0 | `Apache License 2.0` | <https://github.com/JohnTitor/mach2> |
|
||||||
| miniz_oxide | 0.8.9 | `Apache License 2.0` | <https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide> |
|
| miniz_oxide | 0.8.9 | `Apache License 2.0` | <https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide> |
|
||||||
| ndk-context | 0.1.1 | `Apache License 2.0` | <https://github.com/rust-windowing/android-ndk-rs> |
|
| ndk-context | 0.1.1 | `Apache License 2.0` | <https://github.com/rust-windowing/android-ndk-rs> |
|
||||||
| ndk-sys | 0.6.0+11769913 | `Apache License 2.0` | <https://github.com/rust-mobile/ndk> |
|
| ndk-sys | 0.6.0+11769913 | `Apache License 2.0` | <https://github.com/rust-mobile/ndk> |
|
||||||
@@ -380,7 +386,7 @@ those terms.
|
|||||||
| aws-lc-rs | 1.17.0 | `ISC License` | <https://github.com/aws/aws-lc-rs> |
|
| aws-lc-rs | 1.17.0 | `ISC License` | <https://github.com/aws/aws-lc-rs> |
|
||||||
| aws-lc-sys | 0.41.0 | `ISC License` | <https://github.com/aws/aws-lc-rs> |
|
| aws-lc-sys | 0.41.0 | `ISC License` | <https://github.com/aws/aws-lc-rs> |
|
||||||
| uds_windows | 1.2.1 | `MIT License` | <https://github.com/haraldh/rust_uds_windows> |
|
| uds_windows | 1.2.1 | `MIT License` | <https://github.com/haraldh/rust_uds_windows> |
|
||||||
| sdl2-sys | 0.38.0 | `MIT License` | <https://github.com/rust-sdl2/rust-sdl2> |
|
| sdl2-sys | 0.37.0 | `MIT License` | <https://github.com/rust-sdl2/rust-sdl2> |
|
||||||
| openssl-sys | 0.9.116 | `MIT License` | <https://github.com/rust-openssl/rust-openssl> |
|
| openssl-sys | 0.9.116 | `MIT License` | <https://github.com/rust-openssl/rust-openssl> |
|
||||||
| mio | 1.2.1 | `MIT License` | <https://github.com/tokio-rs/mio> |
|
| mio | 1.2.1 | `MIT License` | <https://github.com/tokio-rs/mio> |
|
||||||
| nom | 7.1.3 | `MIT License` | <https://github.com/Geal/nom> |
|
| nom | 7.1.3 | `MIT License` | <https://github.com/Geal/nom> |
|
||||||
@@ -437,7 +443,7 @@ those terms.
|
|||||||
| zmij | 1.0.21 | `MIT License` | <https://github.com/dtolnay/zmij> |
|
| zmij | 1.0.21 | `MIT License` | <https://github.com/dtolnay/zmij> |
|
||||||
| zvariant_utils | 3.4.0 | `MIT License` | <https://github.com/z-galaxy/zbus/> |
|
| zvariant_utils | 3.4.0 | `MIT License` | <https://github.com/z-galaxy/zbus/> |
|
||||||
| winnow | 1.0.3 | `MIT License` | <https://github.com/winnow-rs/winnow> |
|
| winnow | 1.0.3 | `MIT License` | <https://github.com/winnow-rs/winnow> |
|
||||||
| sdl2 | 0.38.0 | `MIT License` | <https://github.com/Rust-SDL2/rust-sdl2> |
|
| sdl2 | 0.37.0 | `MIT License` | <https://github.com/Rust-SDL2/rust-sdl2> |
|
||||||
| aho-corasick | 1.1.4 | `MIT License` | <https://github.com/BurntSushi/aho-corasick> |
|
| aho-corasick | 1.1.4 | `MIT License` | <https://github.com/BurntSushi/aho-corasick> |
|
||||||
| byteorder | 1.5.0 | `MIT License` | <https://github.com/BurntSushi/byteorder> |
|
| byteorder | 1.5.0 | `MIT License` | <https://github.com/BurntSushi/byteorder> |
|
||||||
| csv-core | 0.1.13 | `MIT License` | <https://github.com/BurntSushi/rust-csv> |
|
| csv-core | 0.1.13 | `MIT License` | <https://github.com/BurntSushi/rust-csv> |
|
||||||
@@ -472,7 +478,7 @@ those terms.
|
|||||||
| zerotrie | 0.2.4 | `Unicode License v3` | <https://github.com/unicode-org/icu4x> |
|
| zerotrie | 0.2.4 | `Unicode License v3` | <https://github.com/unicode-org/icu4x> |
|
||||||
| zerovec-derive | 0.11.3 | `Unicode License v3` | <https://github.com/unicode-org/icu4x> |
|
| zerovec-derive | 0.11.3 | `Unicode License v3` | <https://github.com/unicode-org/icu4x> |
|
||||||
| zerovec | 0.11.6 | `Unicode License v3` | <https://github.com/unicode-org/icu4x> |
|
| zerovec | 0.11.6 | `Unicode License v3` | <https://github.com/unicode-org/icu4x> |
|
||||||
| sdl2-sys | 0.38.0 | `zlib License` | <https://github.com/rust-sdl2/rust-sdl2> |
|
| sdl2-sys | 0.37.0 | `zlib License` | <https://github.com/rust-sdl2/rust-sdl2> |
|
||||||
|
|
||||||
## Full license texts
|
## Full license texts
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1317,7 +1317,7 @@ Therefore:
|
|||||||
- Analysis: Feasible with current Flutter + Rust Core architecture; refine in SAD/SDD as needed.
|
- Analysis: Feasible with current Flutter + Rust Core architecture; refine in SAD/SDD as needed.
|
||||||
- Owner: Software Team
|
- Owner: Software Team
|
||||||
|
|
||||||
**SRS-110**: The iOS software shall integrate with AVAudioSession or equivalent platform audio session behavior for foreground voice sessions. The session shall be configured for VoIP (`.playAndRecord` + `.voiceChat` + `.mixWithOthers`) only while a voice channel is active, and shall return to a non-disruptive idle state (`.ambient`, inactive, with `.notifyOthersOnDeactivation`) at all other times so that other apps' audio (music, podcasts, navigation) is preserved when the user opens Chanora to read text chat.
|
**SRS-110**: The iOS software shall integrate with AVAudioSession or equivalent platform audio session behavior for foreground voice sessions.
|
||||||
|
|
||||||
- Type: Platform / iOS
|
- Type: Platform / iOS
|
||||||
- Stage: P0 / MVP
|
- Stage: P0 / MVP
|
||||||
|
|||||||
@@ -1,642 +0,0 @@
|
|||||||
# Chat Panel Switching Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Enable in-place conversation switching in the expanded 3-panel layout, add per-conversation draft persistence, and improve unread awareness — matching industry-standard UX patterns from Discord/Slack/Telegram/Element.
|
|
||||||
|
|
||||||
**Architecture:** The expanded layout (≥1024dp) shows voice controls | channel tree | inline chat panel. Currently the chat panel locks to one conversation with no way to switch from the channel tree. The fix adds channel→chat triggers, in-place target swapping, per-target draft storage, and preserves chat state across switches. The state machine (`_inlineChatTarget` + `_chatOpen`) already supports switching — we just need UI affordances and draft persistence.
|
|
||||||
|
|
||||||
**Tech Stack:** Flutter/Dart, existing `BridgeMessageTarget` sealed class, existing `ChatDetailView` / `ChatPanel` / `SnapshotView` widgets.
|
|
||||||
|
|
||||||
**Design research basis:** Discord (in-place swap, dot/badge unread hierarchy), Telegram Desktop (adaptive 3-tier layout, per-conversation drafts + scroll anchoring), Element (per-room panel state, toggleable right panel), Slack (bold sidebar for unread, split view). All apps treat DMs and channels identically for switching behavior.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
|
|
||||||
| UX Element | Status |
|
|
||||||
|---|---|
|
|
||||||
| Unread indicator | Single global badge count on app bar chat button |
|
|
||||||
| Channel → chat trigger | **None.** Channel tiles only join voice |
|
|
||||||
| Client → DM trigger | Works (right-click → "Direct Message") |
|
|
||||||
| Header chat button when panel open | Idempotent — re-uses same `_inlineChatTarget` |
|
|
||||||
| Draft persistence | None — single `TextEditingController`, lost on switch |
|
|
||||||
| Scroll position memory | None — always auto-scrolls to bottom |
|
|
||||||
| Per-conversation unread | None |
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
**In scope (this plan):**
|
|
||||||
- Channel → chat switching in expanded layout
|
|
||||||
- Channel right-click → "Chat" option
|
|
||||||
- Header chat button → switch to current voice channel chat when panel already open
|
|
||||||
- Per-target draft persistence (in-memory `Map`)
|
|
||||||
- Close = dismiss (remember last target and draft)
|
|
||||||
- Unread dot indicator on channels in `SnapshotView`
|
|
||||||
|
|
||||||
**Out of scope (future):**
|
|
||||||
- Scroll position memory per target
|
|
||||||
- "New messages" divider
|
|
||||||
- Per-target unread counts / badge numbers
|
|
||||||
- Notification tiering (dot/badge/mention)
|
|
||||||
- Split view (Slack power-user feature)
|
|
||||||
|
|
||||||
## Responsive Behavior
|
|
||||||
|
|
||||||
| Tier | Width | Chat Mode | Changes in this plan |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **Expanded** | ≥1024dp | Inline `ChatPanel` (right column) | ✅ All changes apply here |
|
|
||||||
| **Medium** | 600–1023dp | Full-screen `ChatPage` route | No changes needed (already works) |
|
|
||||||
| **Compact** | <600dp | Full-screen `ChatPage` route | No changes needed (already works) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
| File | Action | Responsibility |
|
|
||||||
|---|---|---|
|
|
||||||
| `apps/chanora_flutter/lib/widgets/snapshot_view.dart` | **Modify** | Add `onOpenChannelChat` callback, channel context menu with "Chat" option, unread dot on channels |
|
|
||||||
| `apps/chanora_flutter/lib/main.dart` | **Modify** | Add `_chatDrafts` map, wire `onOpenChannelChat`, fix header chat button to switch to current channel, fix `_closeInlineChat` to preserve last target |
|
|
||||||
| `apps/chanora_flutter/lib/widgets/chat_panel.dart` | **Modify** | Accept `onSwitchTarget` callback, pass draft state through |
|
|
||||||
| `apps/chanora_flutter/lib/widgets/chat_views.dart` | **Modify** | `ChatDetailView` accepts external draft text, exposes draft text on target change |
|
|
||||||
| `apps/chanora_flutter/lib/design/breakpoints.dart` | **No changes** | Breakpoints unchanged |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Add `onOpenChannelChat` callback to `SnapshotView`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart:26-63` (constructor params)
|
|
||||||
- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart:203-284` (`_channelTile`)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the callback field to `SnapshotView` widget**
|
|
||||||
|
|
||||||
In `snapshot_view.dart`, add a new optional callback field after `onOpenClientPoke` (around line 71):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
/// Open chat for a channel.
|
|
||||||
final ValueChanged<rust.BridgeChannel>? onOpenChannelChat;
|
|
||||||
```
|
|
||||||
|
|
||||||
Update the constructor to include it (around line 32, after `onOpenClientPoke`):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
this.onOpenChannelChat,
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add a right-click/long-press context menu to `_channelTile`**
|
|
||||||
|
|
||||||
Replace the `InkWell` in `_channelTile` (lines 243-284) with a context menu wrapper. The channel tile should support:
|
|
||||||
- **Tap**: join voice (existing behavior, unchanged)
|
|
||||||
- **Right-click / long-press**: show a popup menu with "Open chat" option
|
|
||||||
|
|
||||||
```dart
|
|
||||||
return InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
onLongPress: widget.onOpenChannelChat != null
|
|
||||||
? () => widget.onOpenChannelChat!(channel)
|
|
||||||
: null,
|
|
||||||
child: PopupMenuButton<String>(
|
|
||||||
position: PopupMenuPosition.under,
|
|
||||||
enabled: widget.onOpenChannelChat != null,
|
|
||||||
onSelected: (value) {
|
|
||||||
if (value == 'chat') {
|
|
||||||
widget.onOpenChannelChat?.call(channel);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'chat',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.chat_bubble_outline, size: 18),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(AppLocalizations.of(context)!.chatAction),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(minHeight: 40),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(width: channelIndent),
|
|
||||||
_expandButton(
|
|
||||||
theme,
|
|
||||||
hasVisibleChildren: hasVisibleChildren,
|
|
||||||
expanded: expanded,
|
|
||||||
onPressed: onToggleExpanded,
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: _channelIconColumnWidth,
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Icon(
|
|
||||||
Icons.tag,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: _channelTextGap),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
channel.name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (channel.hasPassword) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
Icons.lock_outline,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: The `PopupMenuButton` wraps the existing content as its `child`, so the tile looks identical until right-clicked. The `onTap` on `InkWell` continues to handle voice join.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run `flutter analyze`**
|
|
||||||
|
|
||||||
Run: `cd apps/chanora_flutter && flutter analyze`
|
|
||||||
Expected: No new errors (the callback is optional, so existing call sites compile without changes)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add apps/chanora_flutter/lib/widgets/snapshot_view.dart
|
|
||||||
git commit -m "feat(chat): add onOpenChannelChat callback with context menu to channel tiles"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Wire `onOpenChannelChat` in `main.dart` and add per-target draft storage
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `apps/chanora_flutter/lib/main.dart:370-374` (state fields)
|
|
||||||
- Modify: `apps/chanora_flutter/lib/main.dart:2521-2540` (SnapshotView constructor)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add draft storage map**
|
|
||||||
|
|
||||||
Add a new state field near line 374 (after `_inlineChatCollapseNoticeShown`):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
/// Per-target draft text. Populated when switching away from a conversation
|
|
||||||
/// so the user's unfinished message is preserved.
|
|
||||||
final Map<String, String> _chatDrafts = {};
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `_lastDismissedTarget` field**
|
|
||||||
|
|
||||||
Add a new state field to remember the last dismissed target so reopening returns to it:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
/// The last chat target before the panel was closed. Used to restore the
|
|
||||||
/// previous conversation when the user reopens chat.
|
|
||||||
rust.BridgeMessageTarget? _lastDismissedTarget;
|
|
||||||
String _lastDismissedClientName = '';
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Wire `onOpenChannelChat` in `SnapshotView` constructor**
|
|
||||||
|
|
||||||
In the `SnapshotView(...)` constructor around line 2512, add the new callback:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
onOpenChannelChat: (channel) => unawaited(
|
|
||||||
_onOpenChat(
|
|
||||||
target: rust.BridgeMessageTarget.channel(channel.id),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run `flutter analyze`**
|
|
||||||
|
|
||||||
Run: `cd apps/chanora_flutter && flutter analyze`
|
|
||||||
Expected: No new errors
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add apps/chanora_flutter/lib/main.dart
|
|
||||||
git commit -m "feat(chat): add per-target draft storage and wire onOpenChannelChat"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Fix `_onOpenChat` to support switching and draft save/restore
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `apps/chanora_flutter/lib/main.dart:1628-1685` (`_onOpenChat` and `_closeInlineChat`)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update `_onOpenChat` to save current draft and restore new target's draft**
|
|
||||||
|
|
||||||
Replace the `_onOpenChat` method (lines 1628-1676) with logic that:
|
|
||||||
1. Saves the current `_inlineChatTarget` draft before switching
|
|
||||||
2. Restores the new target's draft (if any)
|
|
||||||
3. When called with no explicit target and panel is already open, switches to current voice channel's chat
|
|
||||||
|
|
||||||
```dart
|
|
||||||
Future<void> _onOpenChat({
|
|
||||||
rust.BridgeMessageTarget? target,
|
|
||||||
String clientName = '',
|
|
||||||
}) async {
|
|
||||||
final initialSnapshot = _snapshot!;
|
|
||||||
|
|
||||||
// Resolve the new target.
|
|
||||||
// If no target passed and panel is already open, switch to current voice channel.
|
|
||||||
// If no target passed and panel is closed, resolve from history or default.
|
|
||||||
rust.BridgeMessageTarget newTarget;
|
|
||||||
if (target != null) {
|
|
||||||
newTarget = target;
|
|
||||||
} else if (_chatOpen && _currentVoiceChannelId != null) {
|
|
||||||
newTarget = rust.BridgeMessageTarget.channel(_currentVoiceChannelId!);
|
|
||||||
} else if (_inlineChatTarget != null) {
|
|
||||||
newTarget = _inlineChatTarget!;
|
|
||||||
} else {
|
|
||||||
newTarget = resolveInitialChatTarget(
|
|
||||||
messages: _chatMessages,
|
|
||||||
currentVoiceChannelId: _currentVoiceChannelId,
|
|
||||||
) ?? const rust.BridgeMessageTarget.server();
|
|
||||||
}
|
|
||||||
|
|
||||||
final newClientName = clientName.isNotEmpty
|
|
||||||
? clientName
|
|
||||||
: (newTarget == _inlineChatTarget) ? _inlineChatClientName : '';
|
|
||||||
|
|
||||||
final isExpanded =
|
|
||||||
layoutClassFromWidth(MediaQuery.sizeOf(context).width) ==
|
|
||||||
LayoutClass.expanded;
|
|
||||||
if (isExpanded) {
|
|
||||||
setState(() {
|
|
||||||
// Save draft for the current target before switching.
|
|
||||||
_saveCurrentDraft();
|
|
||||||
_chatUnread = 0;
|
|
||||||
_chatOpen = true;
|
|
||||||
_inlineChatTarget = newTarget;
|
|
||||||
_inlineChatClientName = newClientName;
|
|
||||||
_inlineChatCollapseNoticeShown = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_chatUnread = 0;
|
|
||||||
_chatOpen = true;
|
|
||||||
});
|
|
||||||
await Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => ChatPage(
|
|
||||||
messages: _chatMessages,
|
|
||||||
snapshot: initialSnapshot,
|
|
||||||
messagesSource: () => _chatMessages,
|
|
||||||
snapshotSource: () => _snapshot ?? initialSnapshot,
|
|
||||||
refreshListenable: _chatFeedRevision,
|
|
||||||
initialTarget: newTarget,
|
|
||||||
initialClientName: newClientName,
|
|
||||||
onTs3ServerLink: _onTs3ServerLink,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (mounted) setState(() => _chatOpen = false);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `_saveCurrentDraft` and `_draftKeyForTarget` helper methods**
|
|
||||||
|
|
||||||
Add these near `_onOpenChat`:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
/// Converts a [rust.BridgeMessageTarget] to a stable string key for draft storage.
|
|
||||||
String _draftKeyForTarget(rust.BridgeMessageTarget target) {
|
|
||||||
return switch (target) {
|
|
||||||
rust.BridgeMessageTarget_Server() => 'server',
|
|
||||||
rust.BridgeMessageTarget_Channel(:final id) => 'channel:$id',
|
|
||||||
rust.BridgeMessageTarget_Client(:final id) => 'client:$id',
|
|
||||||
rust.BridgeMessageTarget_Poke(:final id) => 'poke:$id',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Saves the current draft text (if any) for the current inline chat target.
|
|
||||||
/// Called before switching targets or closing the panel.
|
|
||||||
void _saveCurrentDraft() {
|
|
||||||
// Note: The actual draft text is read from ChatDetailView's
|
|
||||||
// TextEditingController via a callback. This is wired in Task 4.
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `_saveCurrentDraft` will be completed in Task 4 when we wire the draft callback from `ChatDetailView`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update `_closeInlineChat` to preserve last target instead of nulling it**
|
|
||||||
|
|
||||||
Replace `_closeInlineChat` (lines 1678-1685):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
void _closeInlineChat() {
|
|
||||||
setState(() {
|
|
||||||
// Save draft before closing.
|
|
||||||
_saveCurrentDraft();
|
|
||||||
// Remember the last target so reopening returns to it.
|
|
||||||
_lastDismissedTarget = _inlineChatTarget;
|
|
||||||
_lastDismissedClientName = _inlineChatClientName;
|
|
||||||
_chatOpen = false;
|
|
||||||
// Do NOT null _inlineChatTarget — we want to remember it for reopen.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run `flutter analyze`**
|
|
||||||
|
|
||||||
Run: `cd apps/chanora_flutter && flutter analyze`
|
|
||||||
Expected: No new errors
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add apps/chanora_flutter/lib/main.dart
|
|
||||||
git commit -m "feat(chat): switch chat target on channel click, save draft before switching, preserve target on close"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Add draft save/restore callback to `ChatDetailView` and `ChatPanel`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `apps/chanora_flutter/lib/widgets/chat_views.dart:1054-1098` (`ChatDetailView` constructor + state)
|
|
||||||
- Modify: `apps/chanora_flutter/lib/widgets/chat_panel.dart:12-75` (`ChatPanel` constructor + build)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add draft callbacks to `ChatDetailView`**
|
|
||||||
|
|
||||||
Add two new optional callbacks to `ChatDetailView` (after `messageMaxWidth` around line 1066):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
/// 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 disposed.
|
|
||||||
final ValueChanged<String>? onDraftChanged;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Implement draft restore in `_ChatDetailViewState`**
|
|
||||||
|
|
||||||
In `_ChatDetailViewState` (line 1100), add `initState` and `didUpdateWidget` to handle drafts:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
@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) {
|
|
||||||
// Save draft for old target before switching.
|
|
||||||
if (oldWidget.onDraftChanged != null && _textCtl.text.isNotEmpty) {
|
|
||||||
oldWidget.onDraftChanged!(_textCtl.text);
|
|
||||||
}
|
|
||||||
// Restore draft for new target.
|
|
||||||
_textCtl.text = widget.restoredDraft ?? '';
|
|
||||||
_lastRenderedTarget = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
// Emit the current draft so the parent can save it.
|
|
||||||
if (widget.onDraftChanged != null && _textCtl.text.isNotEmpty) {
|
|
||||||
widget.onDraftChanged!(_textCtl.text);
|
|
||||||
}
|
|
||||||
_textCtl.dispose();
|
|
||||||
_scrollCtl.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Remove the existing `dispose` method (lines 1127-1132) — it's replaced by the new one above.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Thread draft callbacks through `ChatPanel`**
|
|
||||||
|
|
||||||
Update `ChatPanel` to accept and pass through the new callbacks. Add fields:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
/// External draft text to restore in the chat detail view.
|
|
||||||
final String? restoredDraft;
|
|
||||||
|
|
||||||
/// Called when the draft text changes.
|
|
||||||
final ValueChanged<String>? onDraftChanged;
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass them through in `build()` where `ChatDetailView` is constructed (line 58):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
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: 'Close chat',
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: onClose,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Wire draft callbacks in `main.dart`**
|
|
||||||
|
|
||||||
In the `ChatPanel(...)` constructor around line 2567, add the draft callbacks:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
ChatPanel(
|
|
||||||
messages: _chatMessages,
|
|
||||||
snapshot: _snapshot!,
|
|
||||||
target: inlineChatTarget,
|
|
||||||
clientName: _inlineChatClientName,
|
|
||||||
onTs3ServerLink: _onTs3ServerLink,
|
|
||||||
restoredDraft: _chatDrafts[_draftKeyForTarget(inlineChatTarget)],
|
|
||||||
onDraftChanged: (text) {
|
|
||||||
_chatDrafts[_draftKeyForTarget(_inlineChatTarget!)] = text;
|
|
||||||
},
|
|
||||||
onClose: _closeInlineChat,
|
|
||||||
),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Complete `_saveCurrentDraft` in `main.dart`**
|
|
||||||
|
|
||||||
The `_saveCurrentDraft` method is called from `_onOpenChat` (before switching) and `_closeInlineChat`. Since `ChatDetailView` emits drafts via `onDraftChanged` and `dispose`, the parent always has the latest draft in `_chatDrafts`. The method body stays as a no-op safety net:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
void _saveCurrentDraft() {
|
|
||||||
// Drafts are continuously saved via onDraftChanged callback.
|
|
||||||
// This method exists as an explicit save point for any future
|
|
||||||
// snapshot-based draft capture.
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run `flutter analyze`**
|
|
||||||
|
|
||||||
Run: `cd apps/chanora_flutter && flutter analyze`
|
|
||||||
Expected: No new errors
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add apps/chanora_flutter/lib/widgets/chat_views.dart apps/chanora_flutter/lib/widgets/chat_panel.dart apps/chanora_flutter/lib/main.dart
|
|
||||||
git commit -m "feat(chat): per-target draft persistence with save/restore on switch"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Add unread dot indicator to channel tiles in `SnapshotView`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart` (add unread indicator)
|
|
||||||
- Modify: `apps/chanora_flutter/lib/main.dart` (pass unread channel set)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add unread channel IDs parameter to `SnapshotView`**
|
|
||||||
|
|
||||||
Add a new required field to `SnapshotView` (after `canJoinVoiceChannel` around line 57):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
/// Set of channel IDs that have unread chat messages.
|
|
||||||
final Set<BigInt> unreadChannelIds;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add unread dot to `_channelTile`**
|
|
||||||
|
|
||||||
In `_channelTile`, inside the `Row` children (after the channel name `Expanded` widget, around line 273), add an unread dot:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// Unread indicator.
|
|
||||||
if (widget.unreadChannelIds.contains(channel.id)) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Container(
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
```
|
|
||||||
|
|
||||||
This must come before the password lock icon check (line 274).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Compute unread channel set in `main.dart`**
|
|
||||||
|
|
||||||
Add a getter in `_BetaHomeState` that computes which channels have unread messages:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
/// Channel IDs that have unread chat messages (used for dot indicators).
|
|
||||||
Set<BigInt> get _unreadChannelIds {
|
|
||||||
if (_chatOpen) return const {};
|
|
||||||
final ids = <BigInt>{};
|
|
||||||
for (final entry in _chatMessages) {
|
|
||||||
if (!entry.countsTowardUnread || entry.isSelf) continue;
|
|
||||||
if (entry.target case rust.BridgeMessageTarget_Channel(:final id)) {
|
|
||||||
ids.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Pass unread channel set to `SnapshotView`**
|
|
||||||
|
|
||||||
In the `SnapshotView(...)` constructor around line 2512, add:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
unreadChannelIds: _unreadChannelIds,
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run `flutter analyze`**
|
|
||||||
|
|
||||||
Run: `cd apps/chanora_flutter && flutter analyze`
|
|
||||||
Expected: No new errors
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add apps/chanora_flutter/lib/widgets/snapshot_view.dart apps/chanora_flutter/lib/main.dart
|
|
||||||
git commit -m "feat(chat): unread dot indicator on channel tiles with unread messages"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: End-to-end verification
|
|
||||||
|
|
||||||
**Files:** All modified files.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run `flutter analyze` on the full project**
|
|
||||||
|
|
||||||
Run: `cd apps/chanora_flutter && flutter analyze`
|
|
||||||
Expected: Zero issues
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run `flutter test`**
|
|
||||||
|
|
||||||
Run: `cd apps/chanora_flutter && flutter test`
|
|
||||||
Expected: All tests pass (same baseline as before — 180 passed, 2 skipped)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Build macOS release**
|
|
||||||
|
|
||||||
Run: `bash tools/build-macos.sh`
|
|
||||||
Expected: Successful build producing `chanora-v0.2.0-beta.1-macos-aarch64.zip`
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual QA checklist**
|
|
||||||
|
|
||||||
Launch the app and verify:
|
|
||||||
|
|
||||||
1. **Channel → chat switching**: With window ≥1024dp and chat panel open showing Server chat, click a channel in the tree. The chat panel should switch to that channel's chat (messages filter to that channel). Voice join should also happen.
|
|
||||||
|
|
||||||
2. **Channel right-click → Chat**: Right-click a channel → "Open chat". Chat panel should switch to that channel's chat without joining voice.
|
|
||||||
|
|
||||||
3. **Header chat button toggle**: With chat panel open showing a DM, click the header chat button. It should switch to the current voice channel's chat.
|
|
||||||
|
|
||||||
4. **Draft persistence**: Type "hello" in chat input but don't send. Click a different channel. Type "world" in that channel's chat. Switch back to the first channel. The input should show "hello".
|
|
||||||
|
|
||||||
5. **Close and reopen**: Close the chat panel. Click the header chat button. It should reopen to the last conversation with the draft intact.
|
|
||||||
|
|
||||||
6. **Unread dots**: Close the chat panel. Have someone send a message to a specific channel. That channel in the tree should show a blue dot.
|
|
||||||
|
|
||||||
7. **Medium/compact unchanged**: Narrow the window below 1024dp. Open chat. It should still push a full-screen route as before. No regressions.
|
|
||||||
|
|
||||||
8. **DM switching still works**: Right-click a client → "Direct Message". Chat panel should switch to that DM. Right-click another client → "Direct Message". Should switch again.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
### Spec coverage
|
|
||||||
|
|
||||||
| Requirement | Task |
|
|
||||||
|---|---|
|
|
||||||
| Channel → chat switching (tap) | Task 2 (wiring) + Task 3 (target resolution) |
|
|
||||||
| Channel → chat (context menu) | Task 1 |
|
|
||||||
| Header button switches when open | Task 3 |
|
|
||||||
| Per-target draft persistence | Task 4 |
|
|
||||||
| Close = dismiss (remember state) | Task 3 |
|
|
||||||
| Unread dot on channels | Task 5 |
|
|
||||||
| Medium/compact unchanged | No changes to those paths |
|
|
||||||
|
|
||||||
### Placeholder scan
|
|
||||||
No TBD, TODO, or placeholder steps found. All code blocks contain complete implementations.
|
|
||||||
|
|
||||||
### Type consistency
|
|
||||||
- `BridgeMessageTarget.channel(id)` uses `BigInt` — matches `channel.id` type
|
|
||||||
- `_chatDrafts` uses `String` keys from `_draftKeyForTarget` — consistent
|
|
||||||
- `onOpenChannelChat` callback type `ValueChanged<rust.BridgeChannel>?` — matches widget pattern
|
|
||||||
- `unreadChannelIds` uses `Set<BigInt>` — matches channel ID type
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# Core Internal Split Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Improve `chanora_core` maintainability by moving stable event DTOs and network diagnostic state out of the oversized `lib.rs` while preserving the public crate Interface.
|
|
||||||
|
|
||||||
**Architecture:** Keep `lib.rs` as the public Interface and session orchestration entry point. Move event-facing DTOs to `events.rs` and private network diagnostic ring-buffer state to `network_diagnostics.rs`; re-export public event types from `lib.rs` so downstream callers keep using `chanora_core::SessionEvent` and related names unchanged.
|
|
||||||
|
|
||||||
**Tech Stack:** Rust 2021, Tokio broadcast/watch channels, `thiserror`, existing Cargo workspace tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Move Core Event DTOs
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `core/chanora_core/src/events.rs`
|
|
||||||
- Modify: `core/chanora_core/src/lib.rs`
|
|
||||||
- Verify: `cargo test -p chanora_core`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Preserve current public Interface with tests**
|
|
||||||
|
|
||||||
Run: `cargo test -p chanora_core`
|
|
||||||
Expected: PASS. Existing bridge-facing tests and compile checks prove the current public event names are valid.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create `events.rs` with the moved public event DTOs**
|
|
||||||
|
|
||||||
Move these exact public types from `lib.rs` to `events.rs`:
|
|
||||||
- `PttDescriptorSnapshot`
|
|
||||||
- `PersistedPttBinding`
|
|
||||||
- `SessionEvent`
|
|
||||||
- `VoiceJoinSyncState`
|
|
||||||
- `VoiceJoinErrorCode`
|
|
||||||
- `NetworkState`
|
|
||||||
|
|
||||||
Use local imports in `events.rs` for `chanora_audio::{AudioRoute, PttBackendDescriptor}` and `chanora_protocol::MessageTarget`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Re-export moved types from `lib.rs`**
|
|
||||||
|
|
||||||
Add `mod events;` and `pub use events::{...};` for all moved public types. Remove the original definitions from `lib.rs`.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests**
|
|
||||||
|
|
||||||
Run: `cargo test -p chanora_core`
|
|
||||||
Expected: PASS with no public Interface break.
|
|
||||||
|
|
||||||
### Task 2: Move Core Network Diagnostics State
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `core/chanora_core/src/network_diagnostics.rs`
|
|
||||||
- Modify: `core/chanora_core/src/lib.rs`
|
|
||||||
- Verify: `cargo test -p chanora_core network_diagnostics`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Move `NetworkDiagnostics` into a private module**
|
|
||||||
|
|
||||||
Move the private `NetworkDiagnostics` struct and its methods from `lib.rs` into `network_diagnostics.rs`. Keep methods `pub(crate)` because `ChanoraSession` records connection/loss events and exports summaries.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Move the regression test with the module**
|
|
||||||
|
|
||||||
Move `network_diagnostics_keeps_last_eight_loss_reasons` from the `lib.rs` test module into `network_diagnostics.rs` so the behaviour test lives next to the Implementation it protects.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Import the private module from `lib.rs`**
|
|
||||||
|
|
||||||
Add `mod network_diagnostics;` and `use network_diagnostics::NetworkDiagnostics;`. Remove `VecDeque` from the `lib.rs` imports.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run targeted and workspace verification**
|
|
||||||
|
|
||||||
Run: `cargo test -p chanora_core network_diagnostics`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
Run: `cargo test --workspace`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 3: Format and Check Workspace
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: Rust files touched above only, except existing formatter-only churn may remain from prior `cargo fmt --all`.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Format Rust code**
|
|
||||||
|
|
||||||
Run: `cargo fmt --all`
|
|
||||||
Expected: no command output.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Compile workspace**
|
|
||||||
|
|
||||||
Run: `cargo check --workspace`
|
|
||||||
Expected: finishes successfully.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Inspect diff**
|
|
||||||
|
|
||||||
Run: `git diff --stat`
|
|
||||||
Expected: new `events.rs` and `network_diagnostics.rs`; smaller `core/chanora_core/src/lib.rs`; no public API renames.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Self-review: This plan covers the recommended Core split first slice, avoids public Interface changes, has no placeholders, and keeps testing tied to the moved Implementations.
|
|
||||||
@@ -1,608 +0,0 @@
|
|||||||
# Maintainability Continuation Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Continue the current maintainability review with safe simplifications, full code-review remediation, current documentation, explicit fail-safe evidence, and Android runtime verification status.
|
|
||||||
|
|
||||||
**Current status:** Task 0 and the audio-realtime portion of Task 0.1 have landed in commits `d835394` and `8606eb4`. Task 0.2 is a documentation/status alignment slice only; it must not claim Android or iOS runtime success.
|
|
||||||
|
|
||||||
**Architecture:** Treat the existing uncommitted maintainability changes as the baseline slice. Fix safety and hidden-bug findings before broad Module splits. Preserve public Rust Core, Bridge, Protocol, Audio, and Flutter responsibilities while applying only tested simplifications and risk-reducing refactors. Record larger seam decisions as follow-up findings unless a huge Module must be split to make a safety fix testable.
|
|
||||||
|
|
||||||
**Tech Stack:** Rust 2021 Cargo workspace, Flutter/Dart 3.11, Flutter Rust Bridge 2.12, Android ADB, Markdown governance and verification documents.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
Implementation should keep the following responsibilities stable:
|
|
||||||
|
|
||||||
- `core/chanora_core/src/lib.rs`: public Core API, session orchestration, public re-exports, and integration-facing methods.
|
|
||||||
- `core/chanora_core/src/events.rs`: Core public event DTOs re-exported by `lib.rs`.
|
|
||||||
- `core/chanora_core/src/network_diagnostics.rs`: private bounded network diagnostic history and its local regression tests.
|
|
||||||
- `crates/chanora_audio/src/voice_render.rs`: shared render/downmix helpers.
|
|
||||||
- `crates/chanora_audio/src/ios_raw_unit.rs`, `ios_voice_unit.rs`, `android_voice_unit.rs`, `engine.rs`: platform/audio backends; avoid broad rewrites without device evidence.
|
|
||||||
- `crates/chanora_audio/src/ptt_backends/mod.rs`: PTT backend descriptor and error definitions.
|
|
||||||
- `crates/chanora_state/src/lib.rs`: snapshot reducers and state deltas.
|
|
||||||
- `crates/chanora_protocol/src/adapter.rs`: protocol adapter event ordering and DTO projection.
|
|
||||||
- `crates/chanora_bridge/src/api.rs`: bridge-facing API and DTO mapping; generated files are not manually edited.
|
|
||||||
- `crates/chanora_diagnostics/src/lib.rs`: bounded diagnostics, redaction, and export data.
|
|
||||||
- `docs/governance/maintainability-review-2026-06-08.md`: working review record and fail-safe gap log.
|
|
||||||
- `docs/governance/document-index.md`: navigation index for review records.
|
|
||||||
- `docs/architecture/sad.md`, `docs/architecture/sdd.md`: architecture updates for seams and implementation boundaries.
|
|
||||||
- `docs/implementation-status-2026-05-28.md`: implementation status updates.
|
|
||||||
- `docs/verification/swe4-unit-verification-plan.md`, `docs/verification/swe5-software-integration-verification-plan.md`: verification evidence and requirements updates.
|
|
||||||
|
|
||||||
## Review Findings To Remediate First
|
|
||||||
|
|
||||||
The full review added these priority fixes before the original maintainability cleanup sequence:
|
|
||||||
|
|
||||||
- Flutter privacy fail-safe: talk-power recovery must not clear a user/manual mute. Fixed in commit `d835394`.
|
|
||||||
- Flutter stuck-transmit fail-safe: touch PTT must release when disposed while held. Fixed in commit `d835394`.
|
|
||||||
- Flutter/iOS fail-safe: iOS audio-session activation errors must be caught, and activation should be moved before Rust VoiceProcessingIO startup where the current app flow allows. Missing-plugin/error hardening fixed in commit `d835394`; iOS device runtime verification remains required.
|
|
||||||
- Rust realtime safety: Android and iOS raw render-reference buffers must not use unsynchronized mutable aliasing. Callback-path hardening fixed in commit `8606eb4`; the full lock-free `AudioHandler` / config / debug-recorder redesign remains a follow-up.
|
|
||||||
- Rust realtime safety: Android input callback must not block on a mutex, and non-48 kHz capture should not allocate/clone per callback. Focused callback-path hardening fixed in commit `8606eb4`; Android target compilation and runtime verification remain blocked locally until the missing NDK compiler and an authorized ADB target are available.
|
|
||||||
- Rust control-plane safety: disconnect and protocol control requests must remain bounded under broken transport or sustained voice traffic.
|
|
||||||
- Governance correctness: README/release/VAD/Android API/product-decision docs must not contradict code or verification status.
|
|
||||||
|
|
||||||
## Task 0: Fix Flutter Privacy and Stuck-Transmit Fail-Safes
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `apps/chanora_flutter/lib/main.dart`
|
|
||||||
- Modify: `apps/chanora_flutter/lib/widgets/voice_compact.dart`
|
|
||||||
- Modify: `apps/chanora_flutter/lib/services/ios_audio_session_controller.dart`
|
|
||||||
- Test: `apps/chanora_flutter/test/widgets/voice_compact_test.dart`
|
|
||||||
- Test: existing Flutter tests under `apps/chanora_flutter/test/`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add failing touch PTT disposal regression test**
|
|
||||||
|
|
||||||
Create or update `apps/chanora_flutter/test/widgets/voice_compact_test.dart` with a widget test that presses the touch PTT button, replaces the widget without sending pointer-up, and expects the callback sequence `[true, false]`.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run touch PTT test and verify RED**
|
|
||||||
|
|
||||||
Run from `apps/chanora_flutter`: `flutter test test/widgets/voice_compact_test.dart`
|
|
||||||
|
|
||||||
Expected before production fix: FAIL because disposal does not emit `false`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement touch PTT release-on-dispose**
|
|
||||||
|
|
||||||
Add `dispose()` to the touch PTT button state so an active press calls `widget.onHeldChanged(false)` exactly once before disposal.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run touch PTT test and verify GREEN**
|
|
||||||
|
|
||||||
Run from `apps/chanora_flutter`: `flutter test test/widgets/voice_compact_test.dart`
|
|
||||||
|
|
||||||
Expected after fix: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Add or preserve mute-owner regression coverage**
|
|
||||||
|
|
||||||
If an existing pure reducer seam is available, add a failing test for manual mute true -> talk power blocked -> talk power restored. If no testable seam exists, first extract the smallest voice mute owner helper from `main.dart` and test it directly.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Implement independent mute owners**
|
|
||||||
|
|
||||||
Ensure talk-power recovery clears only the talk-power owner and does not clear manual/user mute or permission mute. Effective hard mute is the OR of manual, permission, and talk-power owners.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Harden iOS audio-session controller errors**
|
|
||||||
|
|
||||||
Add tests for `MissingPluginException` in `ios_audio_session_controller_test.dart`, then catch `MissingPluginException` or `Object` so activation/deactivation failures do not become unhandled async errors.
|
|
||||||
|
|
||||||
- [ ] **Step 8: Run focused Flutter verification**
|
|
||||||
|
|
||||||
Run from `apps/chanora_flutter`: `flutter test test/widgets/voice_compact_test.dart test/services/ios_audio_session_controller_test.dart && flutter analyze`
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 9: Commit Flutter fail-safe slice**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add apps/chanora_flutter/lib/main.dart apps/chanora_flutter/lib/widgets/voice_compact.dart apps/chanora_flutter/lib/services/ios_audio_session_controller.dart apps/chanora_flutter/test/widgets/voice_compact_test.dart apps/chanora_flutter/test/services/ios_audio_session_controller_test.dart
|
|
||||||
git commit -m "fix(voice): preserve mute owners and release touch ptt"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: one commit containing only Flutter fail-safe fixes and tests.
|
|
||||||
|
|
||||||
## Task 0.1: Fix Rust Realtime and Control-Plane Safety Findings
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `crates/chanora_audio/src/android_voice_unit.rs`
|
|
||||||
- Modify: `crates/chanora_audio/src/ios_raw_unit.rs`
|
|
||||||
- Modify: `crates/chanora_audio/src/engine.rs`
|
|
||||||
- Modify: `crates/chanora_protocol/src/adapter.rs`
|
|
||||||
- Modify: `core/chanora_core/src/lib.rs`
|
|
||||||
- Test: Rust tests in affected crates
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add failing bounded-buffer regression for render-reference handoff**
|
|
||||||
|
|
||||||
Add host-testable unit coverage around the render-reference buffer behavior so a writer can publish a frame and a reader can read a complete latest frame without unsynchronized mutation.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Replace unsafe shared mutable render-reference buffers**
|
|
||||||
|
|
||||||
Replace unsynchronized mutable aliasing in Android and iOS raw render-reference buffers with a realtime-safe handoff such as an `ArrayQueue` of complete frames or a documented atomic double-buffer. Do not add mutex locking to realtime callbacks.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add failing protocol progress regression where feasible**
|
|
||||||
|
|
||||||
Add or isolate a test proving control requests are not starved by sustained voice packet drain.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Bound voice draining and disconnect shutdown**
|
|
||||||
|
|
||||||
Cap voice packet draining per protocol loop and make disconnect/shutdown bounded so UI/Core locks are not held across unbounded transport waits.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run focused Rust verification**
|
|
||||||
|
|
||||||
Run: `cargo test -p chanora_audio -p chanora_protocol -p chanora_core`
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit Rust safety slice**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add crates/chanora_audio/src/android_voice_unit.rs crates/chanora_audio/src/ios_raw_unit.rs crates/chanora_audio/src/engine.rs crates/chanora_protocol/src/adapter.rs core/chanora_core/src/lib.rs
|
|
||||||
git commit -m "fix(audio): harden realtime and protocol fail-safes"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: one commit containing only Rust safety fixes and tests.
|
|
||||||
|
|
||||||
## Task 0.2: Align Review Findings With Specs, Plans, and Governance Docs
|
|
||||||
|
|
||||||
**Status:** In progress / documentation-only alignment. Do not commit from this task unless explicitly requested.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `docs/superpowers/specs/2026-06-08-maintainability-continuation-design.md`
|
|
||||||
- Modify: `docs/superpowers/plans/2026-06-08-maintainability-continuation.md`
|
|
||||||
- Modify: `docs/governance/maintainability-review-2026-06-08.md`
|
|
||||||
- Modify: `README.md`
|
|
||||||
- Modify if needed: `CHANGELOG.md`
|
|
||||||
- Modify if needed: `docs/governance/product-decision-register.md`
|
|
||||||
- Modify if needed: `docs/release/release-readiness-go-nogo-record.md`
|
|
||||||
- Modify if needed: `docs/release/dv-waiver-register.md`
|
|
||||||
- Modify if needed: `docs/verification/verification-master-plan.md`
|
|
||||||
- Modify if needed: `docs/verification/sys4-system-integration-verification-plan.md`
|
|
||||||
|
|
||||||
- [x] **Step 1: Record full-review findings in maintainability review**
|
|
||||||
|
|
||||||
Update `docs/governance/maintainability-review-2026-06-08.md` with the full code-review findings, fixed items, blocked items, and follow-up Module split candidates.
|
|
||||||
|
|
||||||
- [x] **Step 2: Fix stale platform/release claims**
|
|
||||||
|
|
||||||
Update Android minimum runtime claims to API 28 where code and requirements require it. Update release metadata so Flutter app version/build and Rust workspace version are clearly distinguished.
|
|
||||||
|
|
||||||
- [x] **Step 3: Clarify VAD/VoiceActivity status**
|
|
||||||
|
|
||||||
Document the difference between VAD scaffolding/assets/tests and product-enabled VoiceActivity behavior. Do not claim runtime VoiceActivity is shipped unless verified.
|
|
||||||
|
|
||||||
- [x] **Step 4: Promote Android runtime verification blocker**
|
|
||||||
|
|
||||||
Add Android ADB/build/install/smoke as a blocker or waiver in governing release/verification docs when no authorized target is connected.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit plan/spec/governance alignment slice**
|
|
||||||
|
|
||||||
Skipped in this subagent run because the instruction for Task 0.2 explicitly says not to commit.
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add docs/superpowers/specs/2026-06-08-maintainability-continuation-design.md docs/superpowers/plans/2026-06-08-maintainability-continuation.md docs/governance/maintainability-review-2026-06-08.md README.md CHANGELOG.md docs/governance/product-decision-register.md docs/release/release-readiness-go-nogo-record.md docs/release/dv-waiver-register.md docs/verification/verification-master-plan.md docs/verification/sys4-system-integration-verification-plan.md
|
|
||||||
git commit -m "docs: align review findings and verification gates"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: one documentation/governance commit, with unavailable optional files omitted only if unchanged.
|
|
||||||
|
|
||||||
## Task 1: Verify Current Branch Baseline
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Read: `docs/governance/maintainability-review-2026-06-08.md`
|
|
||||||
- Read: `docs/superpowers/plans/2026-06-08-core-internal-split.md`
|
|
||||||
- Inspect: all currently modified files from `git status --short`
|
|
||||||
- Modify: none unless verification exposes a small mechanical fix
|
|
||||||
|
|
||||||
- [ ] **Step 1: Inspect current status**
|
|
||||||
|
|
||||||
Run: `git status --short`
|
|
||||||
|
|
||||||
Expected: output includes the existing maintainability branch changes and no staged files from unrelated work.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Inspect current diff summary**
|
|
||||||
|
|
||||||
Run: `git diff --stat`
|
|
||||||
|
|
||||||
Expected: diff remains focused on Core split, Audio simplifications, State/Protocol/Bridge/Diagnostics cleanup, and documentation updates.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify Rust formatting**
|
|
||||||
|
|
||||||
Run: `cargo fmt --all --check`
|
|
||||||
|
|
||||||
Expected: PASS with no output. If it fails, run `cargo fmt --all`, inspect the resulting diff, and include formatter-only changes in the smallest relevant commit.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify Rust compilation**
|
|
||||||
|
|
||||||
Run: `cargo check --workspace`
|
|
||||||
|
|
||||||
Expected: PASS for the full workspace.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Verify Rust tests**
|
|
||||||
|
|
||||||
Run: `cargo test --workspace`
|
|
||||||
|
|
||||||
Expected: PASS for the full workspace.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Verify Flutter analysis**
|
|
||||||
|
|
||||||
Run from `apps/chanora_flutter`: `flutter analyze`
|
|
||||||
|
|
||||||
Expected: PASS with no new analyzer errors.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Verify Flutter tests**
|
|
||||||
|
|
||||||
Run from `apps/chanora_flutter`: `flutter test --exclude-tags e2e`
|
|
||||||
|
|
||||||
Expected: PASS for non-e2e Flutter tests.
|
|
||||||
|
|
||||||
- [ ] **Step 8: Check Android device availability**
|
|
||||||
|
|
||||||
Run: `adb devices -l`
|
|
||||||
|
|
||||||
Expected if a target is connected: at least one `device` row. Expected if no target is connected: only the header and no `device` row; record Android runtime verification as blocked in `docs/governance/maintainability-review-2026-06-08.md`.
|
|
||||||
|
|
||||||
- [ ] **Step 9: Commit verified existing slice**
|
|
||||||
|
|
||||||
Only after Steps 1-8 have been completed or any blocked Android status has been documented, stage the smallest coherent existing slice.
|
|
||||||
|
|
||||||
Recommended first slice if tests pass:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add core/chanora_core/src/lib.rs core/chanora_core/src/events.rs core/chanora_core/src/network_diagnostics.rs docs/superpowers/plans/2026-06-08-core-internal-split.md
|
|
||||||
git commit -m "refactor(core): split event and diagnostics internals"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: one commit containing only the Core split and its plan.
|
|
||||||
|
|
||||||
## Task 2: Commit Existing Built-In and Helper Reuse Simplifications
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify or stage: `crates/chanora_audio/src/ptt_backends/mod.rs`
|
|
||||||
- Modify or stage: `crates/chanora_audio/src/voice_render.rs`
|
|
||||||
- Modify or stage: `crates/chanora_audio/src/ios_raw_unit.rs`
|
|
||||||
- Modify or stage: `crates/chanora_diagnostics/src/lib.rs`
|
|
||||||
- Modify or stage: `crates/chanora_state/src/lib.rs`
|
|
||||||
- Modify or stage: `crates/chanora_resolver/Cargo.toml`
|
|
||||||
- Modify or stage: `Cargo.lock`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Inspect simplification diffs**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git diff -- crates/chanora_audio/src/ptt_backends/mod.rs crates/chanora_audio/src/voice_render.rs crates/chanora_audio/src/ios_raw_unit.rs crates/chanora_diagnostics/src/lib.rs crates/chanora_state/src/lib.rs crates/chanora_resolver/Cargo.toml Cargo.lock
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: diffs show built-in/helper reuse only: `thiserror::Error`, `VecDeque`, render helper reuse, reducer reuse, and workspace metadata inheritance.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run focused Rust tests for changed areas**
|
|
||||||
|
|
||||||
Run: `cargo test -p chanora_audio -p chanora_diagnostics -p chanora_state -p chanora_resolver`
|
|
||||||
|
|
||||||
Expected: PASS for all listed crates.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run workspace Rust verification**
|
|
||||||
|
|
||||||
Run: `cargo check --workspace && cargo test --workspace`
|
|
||||||
|
|
||||||
Expected: PASS for workspace compile and tests.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit built-in/helper reuse slice**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add crates/chanora_audio/src/ptt_backends/mod.rs crates/chanora_audio/src/voice_render.rs crates/chanora_audio/src/ios_raw_unit.rs crates/chanora_diagnostics/src/lib.rs crates/chanora_state/src/lib.rs crates/chanora_resolver/Cargo.toml Cargo.lock
|
|
||||||
git commit -m "refactor: reuse built-ins and shared helpers"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: one commit containing only the built-in/helper reuse simplifications.
|
|
||||||
|
|
||||||
## Task 3: Review Remaining Audio Platform Diffs Before Committing
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Inspect: `crates/chanora_audio/src/android_voice_unit.rs`
|
|
||||||
- Inspect: `crates/chanora_audio/src/engine.rs`
|
|
||||||
- Inspect: `crates/chanora_audio/src/ios_voice_unit.rs`
|
|
||||||
- Modify: `docs/governance/maintainability-review-2026-06-08.md` if Android runtime verification is blocked or audio fail-safe evidence changes
|
|
||||||
|
|
||||||
- [ ] **Step 1: Inspect platform-audio diffs**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git diff -- crates/chanora_audio/src/android_voice_unit.rs crates/chanora_audio/src/engine.rs crates/chanora_audio/src/ios_voice_unit.rs
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: diffs are understandable as local simplification or fail-safe improvements. If a diff changes platform runtime behavior and no Android/iOS device evidence is available, keep it separate from non-platform commits.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run audio crate tests**
|
|
||||||
|
|
||||||
Run: `cargo test -p chanora_audio`
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Check Android runtime availability**
|
|
||||||
|
|
||||||
Run: `adb devices -l`
|
|
||||||
|
|
||||||
Expected if connected: at least one target row with `device`. Expected if blocked: no target row.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run Android build when a target is available**
|
|
||||||
|
|
||||||
Run from `apps/chanora_flutter`: `flutter build apk --debug`
|
|
||||||
|
|
||||||
Expected: PASS and a debug APK is produced.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run Android install and smoke when a target is available**
|
|
||||||
|
|
||||||
Run from `apps/chanora_flutter`: `flutter install`
|
|
||||||
|
|
||||||
Expected: PASS and app installs on the connected target.
|
|
||||||
|
|
||||||
Manual smoke expectations:
|
|
||||||
|
|
||||||
- App launches without crash.
|
|
||||||
- Permission UI can be reached.
|
|
||||||
- Audio device/permission screen does not crash.
|
|
||||||
- Voice controls remain responsive.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Record blocked Android evidence if no target is available**
|
|
||||||
|
|
||||||
Modify `docs/governance/maintainability-review-2026-06-08.md` Section 6 so it includes the exact `adb devices -l` result and states Android runtime verification is blocked until a device or emulator is connected.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit platform-audio slice**
|
|
||||||
|
|
||||||
If Android runtime was verified, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add crates/chanora_audio/src/android_voice_unit.rs crates/chanora_audio/src/engine.rs crates/chanora_audio/src/ios_voice_unit.rs docs/governance/maintainability-review-2026-06-08.md
|
|
||||||
git commit -m "refactor(audio): simplify platform voice internals"
|
|
||||||
```
|
|
||||||
|
|
||||||
If Android runtime was blocked, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add crates/chanora_audio/src/android_voice_unit.rs crates/chanora_audio/src/engine.rs crates/chanora_audio/src/ios_voice_unit.rs docs/governance/maintainability-review-2026-06-08.md
|
|
||||||
git commit -m "refactor(audio): simplify platform voice internals"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: commit message is the same, but the maintainability review explicitly records the blocked Android runtime evidence.
|
|
||||||
|
|
||||||
## Task 4: Review Protocol and Bridge Diffs as One Boundary Slice
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Inspect: `crates/chanora_protocol/src/adapter.rs`
|
|
||||||
- Inspect: `crates/chanora_bridge/src/api.rs`
|
|
||||||
- Modify: `docs/architecture/sad.md`
|
|
||||||
- Modify: `docs/architecture/sdd.md`
|
|
||||||
- Modify: `docs/governance/maintainability-review-2026-06-08.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Inspect protocol/bridge diffs**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git diff -- crates/chanora_protocol/src/adapter.rs crates/chanora_bridge/src/api.rs
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: diffs preserve protocol isolation and bridge DTO shape unless bridge generation and Flutter tests are included.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify protocol tests**
|
|
||||||
|
|
||||||
Run: `cargo test -p chanora_protocol`
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify bridge tests and compile**
|
|
||||||
|
|
||||||
Run: `cargo test -p chanora_bridge`
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify Flutter after bridge/API changes**
|
|
||||||
|
|
||||||
Run from `apps/chanora_flutter`: `flutter analyze && flutter test --exclude-tags e2e`
|
|
||||||
|
|
||||||
Expected: PASS for analyzer and non-e2e tests.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Document seam findings**
|
|
||||||
|
|
||||||
Update `docs/governance/maintainability-review-2026-06-08.md` so remaining bridge DTO drift and protocol voice packet seam risks are listed under fail-safe gaps or follow-up opportunities.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Update architecture docs if seam wording changed**
|
|
||||||
|
|
||||||
If the code diff clarifies protocol/bridge boundaries, update `docs/architecture/sad.md` and `docs/architecture/sdd.md` with one concise note each. The note should state whether protocol voice packet handling is an intentional exception and whether bridge DTO mirrors remain required by Flutter Rust Bridge.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit protocol/bridge slice**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add crates/chanora_protocol/src/adapter.rs crates/chanora_bridge/src/api.rs docs/architecture/sad.md docs/architecture/sdd.md docs/governance/maintainability-review-2026-06-08.md
|
|
||||||
git commit -m "refactor: clarify protocol bridge boundaries"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: one commit for protocol/bridge boundary cleanup plus matching architecture documentation.
|
|
||||||
|
|
||||||
## Task 5: Run a Second-Pass Simplification Search
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Inspect: Rust and Dart source files only
|
|
||||||
- Modify: only if the simplification is mechanical, local, and covered by tests
|
|
||||||
- Modify: `docs/governance/maintainability-review-2026-06-08.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Search for custom queue front removal**
|
|
||||||
|
|
||||||
Run: `rg "remove\(0\)|removeAt\(0\)" core crates apps/chanora_flutter/lib apps/chanora_flutter/test`
|
|
||||||
|
|
||||||
Expected: no results. If results exist, replace with `VecDeque` in Rust or a clearer Dart queue structure only when behavior is covered by a local test.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Search for manual Rust error formatting**
|
|
||||||
|
|
||||||
Run: `rg "impl (std::fmt::)?Display for .*Error|impl std::error::Error for" core crates`
|
|
||||||
|
|
||||||
Expected: only intentional manual implementations remain. For simple enum error types, replace with `thiserror::Error` and add or preserve tests for user-facing strings.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Search for duplicate mono downmix loops**
|
|
||||||
|
|
||||||
Run: `rg "chunks_exact\(2\)|downmix|mono" crates/chanora_audio/src`
|
|
||||||
|
|
||||||
Expected: duplicate i16/f32 mono downmix code is either absent or justified. If a duplicate remains, route it through an existing helper and run `cargo test -p chanora_audio`.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Search for shallow modules worth documenting**
|
|
||||||
|
|
||||||
Run: `rg "^pub struct|^pub enum|^pub fn|^fn" crates/chanora_audio/src/processor crates/chanora_prefetch/src crates/chanora_resolver/src core/chanora_core/src`
|
|
||||||
|
|
||||||
Expected: identify candidates, but do not merge modules in this task. Record speculative merges in `docs/governance/maintainability-review-2026-06-08.md` unless a candidate is trivial and already covered by tests.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit second-pass mechanical simplifications if any**
|
|
||||||
|
|
||||||
If code changed, run the relevant focused tests plus `cargo check --workspace && cargo test --workspace`, then inspect the changed files:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git status --short
|
|
||||||
```
|
|
||||||
|
|
||||||
Stage only the files changed by the second-pass mechanical simplification. Example for a Rust-only diagnostics simplification:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add crates/chanora_diagnostics/src/lib.rs docs/governance/maintainability-review-2026-06-08.md
|
|
||||||
git commit -m "refactor: apply second-pass mechanical simplifications"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: commit contains only local mechanical simplifications and the review record.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit review-only findings if no code changed**
|
|
||||||
|
|
||||||
If no code changed and only findings were added, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add docs/governance/maintainability-review-2026-06-08.md
|
|
||||||
git commit -m "docs: record maintainability follow-up findings"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: documentation-only commit.
|
|
||||||
|
|
||||||
## Task 6: Final Documentation Alignment
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `docs/governance/maintainability-review-2026-06-08.md`
|
|
||||||
- Modify: `docs/governance/document-index.md`
|
|
||||||
- Modify: `docs/implementation-status-2026-05-28.md`
|
|
||||||
- Modify: `docs/verification/swe4-unit-verification-plan.md`
|
|
||||||
- Modify: `docs/verification/swe5-software-integration-verification-plan.md`
|
|
||||||
- Modify if needed: `docs/architecture/sad.md`
|
|
||||||
- Modify if needed: `docs/architecture/sdd.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update maintainability review completion status**
|
|
||||||
|
|
||||||
Edit `docs/governance/maintainability-review-2026-06-08.md` so these sections are current:
|
|
||||||
|
|
||||||
- Changes applied
|
|
||||||
- Remaining simplification opportunities
|
|
||||||
- Fail-safe gaps that need evidence
|
|
||||||
- Verification policy
|
|
||||||
- Android ADB status
|
|
||||||
- Git policy
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update document index**
|
|
||||||
|
|
||||||
Ensure `docs/governance/document-index.md` includes `docs/governance/maintainability-review-2026-06-08.md` and this plan/spec if the repository convention indexes superpowers documents.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update implementation status**
|
|
||||||
|
|
||||||
Ensure `docs/implementation-status-2026-05-28.md` describes maintainability review results without claiming production readiness.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update SWE.4 verification plan**
|
|
||||||
|
|
||||||
Ensure `docs/verification/swe4-unit-verification-plan.md` lists Rust unit verification expectations for Core, Audio, State, Protocol, Bridge, Diagnostics, Resolver, and Prefetch when those crates are touched.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update SWE.5 verification plan**
|
|
||||||
|
|
||||||
Ensure `docs/verification/swe5-software-integration-verification-plan.md` lists integration expectations for Bridge DTO drift, protocol event folding, Flutter analyze/test, and Android runtime smoke evidence.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run documentation cross-link search**
|
|
||||||
|
|
||||||
Run: `rg "maintainability-review-2026-06-08|core-internal-split|maintainability-continuation" docs README.md`
|
|
||||||
|
|
||||||
Expected: references point to existing files and no stale path is introduced.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit final documentation alignment**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add docs/governance/maintainability-review-2026-06-08.md docs/governance/document-index.md docs/implementation-status-2026-05-28.md docs/verification/swe4-unit-verification-plan.md docs/verification/swe5-software-integration-verification-plan.md docs/architecture/sad.md docs/architecture/sdd.md
|
|
||||||
git commit -m "docs: align maintainability verification records"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: one documentation-focused commit.
|
|
||||||
|
|
||||||
## Task 7: Final Verification and Android Evidence
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `docs/governance/maintainability-review-2026-06-08.md` only if final verification status changes
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run full Rust verification**
|
|
||||||
|
|
||||||
Run: `cargo fmt --all --check && cargo check --workspace && cargo test --workspace`
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run full Flutter verification**
|
|
||||||
|
|
||||||
Run from `apps/chanora_flutter`: `flutter analyze && flutter test --exclude-tags e2e`
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run ADB check**
|
|
||||||
|
|
||||||
Run: `adb devices -l`
|
|
||||||
|
|
||||||
Expected if connected: at least one target row with `device`. Expected if blocked: no target row and the maintainability review states Android runtime verification is blocked.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run Android build/install/smoke when connected**
|
|
||||||
|
|
||||||
Run from `apps/chanora_flutter`: `flutter build apk --debug && flutter install`
|
|
||||||
|
|
||||||
Expected: PASS. Manually verify app launch, permission screen access, audio settings access, and voice control responsiveness.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit final verification evidence if docs changed**
|
|
||||||
|
|
||||||
If the maintainability review was updated with final verification evidence, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add docs/governance/maintainability-review-2026-06-08.md
|
|
||||||
git commit -m "docs: record maintainability verification evidence"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: one small evidence-only documentation commit.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Inspect final history and status**
|
|
||||||
|
|
||||||
Run: `git status --short && git log --oneline -10`
|
|
||||||
|
|
||||||
Expected: no unexpected unstaged changes related to this work; recent commits are small and logically separated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
Spec coverage:
|
|
||||||
|
|
||||||
- Safe simplifications are covered by Tasks 1, 2, 3, 4, and 5.
|
|
||||||
- Built-in replacement opportunities are covered by Tasks 2 and 5.
|
|
||||||
- Fail-safe gaps are covered by Tasks 3, 4, 6, and 7.
|
|
||||||
- Rust, Flutter, and Android verification are covered by Tasks 1 and 7, with focused verification in Tasks 2 through 4.
|
|
||||||
- Documentation updates are covered by Tasks 4, 6, and 7.
|
|
||||||
- Small commit policy is covered by every task's dedicated commit step.
|
|
||||||
|
|
||||||
Placeholder scan: The plan contains no open placeholders. Task 5 uses `git status --short` before staging because the exact second-pass files are only known after the search runs; the example command shows the required staging style.
|
|
||||||
|
|
||||||
Type consistency: The plan does not introduce new APIs or types. It preserves current crate and file boundaries from the approved design.
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
# Chanora Adaptive 3-Panel Layout Design
|
|
||||||
|
|
||||||
**Date:** 2026-06-05
|
|
||||||
**Status:** Draft
|
|
||||||
**Scope:** Desktop adaptive layout for ≥1024dp three-panel mode, centralized breakpoint system, and chat panel integration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Problem Statement
|
|
||||||
|
|
||||||
Chanora's current responsive layout uses a single breakpoint (`_wideBreakpoint = 600dp`) scattered across 9 files in 8 duplicatable clusters (5 `LayoutBuilder` sites, 7 `MediaQuery.sizeOf` sites). The desktop layout is a 2-panel split (VoiceBar 320px + SnapshotView flex) with no persistent chat surface.
|
|
||||||
|
|
||||||
Research across Discord, Mattermost, Rocket.Chat, Element, and hardware resolution data shows:
|
|
||||||
|
|
||||||
- **1024dp** is the industry-standard threshold where a third panel becomes viable (Discord member list, Mattermost RHS, Rocket.Chat contextual bar all use this value).
|
|
||||||
- At 1024dp, Chanora's math works: `320 + 12 + 300 + 12 + 380 = 1024` — minimum viable for VoicePanel + ChannelTree + ChatPanel.
|
|
||||||
- Production apps use **push/replace navigation** for chat on constrained widths, reserving persistent panels for ≥1024dp.
|
|
||||||
- Centralized breakpoint logic is standard practice (Rocket.Chat `LayoutProvider`, Mattermost `WindowSizes`).
|
|
||||||
|
|
||||||
## 2. Design Decisions
|
|
||||||
|
|
||||||
| Decision | Choice | Rationale |
|
|
||||||
|---|---|---|
|
|
||||||
| 3-panel activation threshold | **1024dp** | Industry consensus (Discord, Mattermost, Rocket.Chat). Chanora math: center pane = 300dp minimum. |
|
|
||||||
| Chat behavior at 600–1023dp | **Push route (unchanged)** | Research validates current pattern. Overlays are for contextual info, not primary conversation. |
|
|
||||||
| Chat behavior at ≥1024dp | **Inline panel** | Chat renders in a 380dp right panel alongside the channel tree. No route push. |
|
|
||||||
| Centralized breakpoints | **New `ChanoraBreakpoints` + `ViewportInfo`** | Replaces 8 duplicated responsive clusters with single source of truth. |
|
|
||||||
| Architecture approach | **Adaptive Scaffold Shell** | Extends existing widget tree with centralized layout logic. Not a full rewrite. |
|
|
||||||
| AdaptiveScaffold package | **Not used** | Package discontinued (flutter/flutter#162965). Manual layout gives better control for voice-first UX. |
|
|
||||||
|
|
||||||
## 3. Breakpoint System
|
|
||||||
|
|
||||||
### 3.1 Layout Classes
|
|
||||||
|
|
||||||
Three tiers, aligned with Material 3 adaptive guidance:
|
|
||||||
|
|
||||||
| Class | Width Range | Primary Behavior |
|
|
||||||
|---|---|---|
|
|
||||||
| `compact` | < 600dp | Single column. VoiceStatusChip at bottom. Chat as pushed route. |
|
|
||||||
| `medium` | 600–1023dp | 2-panel row (VoicePanel 320px + SnapshotView flex). Chat as pushed route. |
|
|
||||||
| `expanded` | ≥ 1024dp | 3-panel row (VoicePanel 320px + SnapshotView flex + ChatPanel 380px). Chat inline. |
|
|
||||||
|
|
||||||
### 3.2 New Files
|
|
||||||
|
|
||||||
**`lib/design/breakpoints.dart`** — canonical breakpoint tokens:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
class ChanoraBreakpoints {
|
|
||||||
static const double compact = 0;
|
|
||||||
static const double medium = 600;
|
|
||||||
static const double expanded = 1024;
|
|
||||||
|
|
||||||
static const double voicePanelWidth = 320;
|
|
||||||
static const double chatPanelWidth = 380;
|
|
||||||
static const double panelGap = 12;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum LayoutClass { compact, medium, expanded }
|
|
||||||
|
|
||||||
LayoutClass layoutClassFromWidth(double width) {
|
|
||||||
if (width >= ChanoraBreakpoints.expanded) return LayoutClass.expanded;
|
|
||||||
if (width >= ChanoraBreakpoints.medium) return LayoutClass.medium;
|
|
||||||
return LayoutClass.compact;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`lib/design/viewport_info.dart`** — inherited widget that computes layout class once per frame:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
class ViewportInfo extends InheritedWidget {
|
|
||||||
const ViewportInfo({
|
|
||||||
super.key,
|
|
||||||
required this.layoutClass,
|
|
||||||
required this.width,
|
|
||||||
required this.height,
|
|
||||||
required super.child,
|
|
||||||
});
|
|
||||||
|
|
||||||
final LayoutClass layoutClass;
|
|
||||||
final double width;
|
|
||||||
final double height;
|
|
||||||
|
|
||||||
static ViewportInfo of(BuildContext context) {
|
|
||||||
final info = context.dependOnInheritedWidgetOfExactType<ViewportInfo>();
|
|
||||||
assert(info != null, 'No ViewportInfo found in widget tree');
|
|
||||||
return info!;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get isCompact => layoutClass == LayoutClass.compact;
|
|
||||||
bool get isMedium => layoutClass == LayoutClass.medium;
|
|
||||||
bool get isExpanded => layoutClass == LayoutClass.expanded;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool updateShouldNotify(ViewportInfo old) =>
|
|
||||||
layoutClass != old.layoutClass ||
|
|
||||||
width != old.width ||
|
|
||||||
height != old.height;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 What This Replaces
|
|
||||||
|
|
||||||
The audit identified these duplicatable clusters that get consolidated:
|
|
||||||
|
|
||||||
| Cluster | Current Locations | Replacement |
|
|
||||||
|---|---|---|
|
|
||||||
| 600dp breakpoint (×3) | `main.dart:304,2280,2468` | `ChanoraBreakpoints.medium` |
|
|
||||||
| 400px cap (×2) | `connect_widgets.dart`, `voice_settings.dart` | Named token in `ChanoraBreakpoints` |
|
|
||||||
| 72% modal height (×2) | `audio_output_tile.dart`, `ptt_capability_badge.dart` | Named token |
|
|
||||||
| 320px voice bar width | `main.dart:2467` | `ChanoraBreakpoints.voicePanelWidth` |
|
|
||||||
| Platform capability branching | `voice_settings.dart`, `voice_compact.dart`, `audio_processing_config_state.dart` | Centralized capability helper |
|
|
||||||
|
|
||||||
## 4. Adaptive Shell
|
|
||||||
|
|
||||||
### 4.1 Widget Tree
|
|
||||||
|
|
||||||
The existing `_BetaHome` widget tree is restructured to use `ViewportInfo`:
|
|
||||||
|
|
||||||
```
|
|
||||||
_BetaHome
|
|
||||||
├─ macOS: Scaffold with traffic-light padding (unchanged)
|
|
||||||
├─ Mobile: ChanoraMobileScaffold (unchanged)
|
|
||||||
└─ bodyContent:
|
|
||||||
└─ LayoutBuilder
|
|
||||||
└─ ViewportInfo (computes layoutClass from constraints)
|
|
||||||
├─ compact: Column [SnapshotView, VoiceStatusChip, PTT]
|
|
||||||
├─ medium: Row [VoicePanel, SnapshotView]
|
|
||||||
└─ expanded: Row [VoicePanel, SnapshotView, ChatPanel]
|
|
||||||
```
|
|
||||||
|
|
||||||
`AdaptiveShell` is a pure layout widget — it reads `ViewportInfo` and composes the appropriate children. All state remains in `_BetaHome`.
|
|
||||||
|
|
||||||
### 4.2 Platform Handling
|
|
||||||
|
|
||||||
Platform-specific scaffolding stays at the top level, unchanged:
|
|
||||||
|
|
||||||
- **macOS**: `Scaffold` with `_macOSTrafficLightPad` top padding (28dp)
|
|
||||||
- **Mobile**: `ChanoraMobileScaffold` with compact idle chrome
|
|
||||||
- **Windows/Linux**: Default `Scaffold`
|
|
||||||
|
|
||||||
The `ViewportInfo` + layout switch only affects the body content inside the scaffold.
|
|
||||||
|
|
||||||
## 5. Chat Panel Behavior
|
|
||||||
|
|
||||||
### 5.1 Compact (< 600dp)
|
|
||||||
|
|
||||||
No change. Chat opens as a pushed `MaterialPageRoute`:
|
|
||||||
|
|
||||||
```
|
|
||||||
main.dart:_onOpenChat → Navigator.push(ChatPage)
|
|
||||||
```
|
|
||||||
|
|
||||||
Channel tree is fully replaced. Back button returns to main view.
|
|
||||||
|
|
||||||
### 5.2 Medium (600–1023dp)
|
|
||||||
|
|
||||||
Same as compact. Chat is a pushed route. The 2-panel layout (VoicePanel + SnapshotView) stays as the home screen.
|
|
||||||
|
|
||||||
### 5.3 Expanded (≥ 1024dp)
|
|
||||||
|
|
||||||
Chat renders inline in a 380dp right panel. The flow:
|
|
||||||
|
|
||||||
1. User taps "Open Text Chat" on a client, or taps the chat badge
|
|
||||||
2. `_onOpenChat` reads `ViewportInfo.of(context).isExpanded`
|
|
||||||
3. If expanded: sets `_inlineChatTarget` state → `ChatPanel` renders in the third column
|
|
||||||
4. If not expanded: pushes `ChatPage` route (unchanged behavior)
|
|
||||||
|
|
||||||
### 5.4 ChatPanel Widget
|
|
||||||
|
|
||||||
New widget for ≥1024dp only:
|
|
||||||
|
|
||||||
```
|
|
||||||
ChatPanel (380dp fixed width)
|
|
||||||
├─ Header: target name + close button
|
|
||||||
├─ Message list (scrollable, max-width ~500dp for readability)
|
|
||||||
└─ Input field
|
|
||||||
```
|
|
||||||
|
|
||||||
**State sharing:** The `_chatMessages` list and `_chatFeedRevision` listenable in `_BetaHome` already track all messages. `ChatPanel` reads from the same source — no duplication.
|
|
||||||
|
|
||||||
**Close behavior:** User taps close button → `_inlineChatTarget` set to null → `ChatPanel` removed from tree.
|
|
||||||
|
|
||||||
### 5.5 Width Transition
|
|
||||||
|
|
||||||
When the user resizes from ≥1024dp to <1024dp while chat is open inline:
|
|
||||||
|
|
||||||
1. `ChatPanel` disappears (it's only in the expanded layout branch)
|
|
||||||
2. A brief snackbar appears: "Tap the chat button to continue your conversation"
|
|
||||||
3. The `_inlineChatTarget` state is preserved — tapping the chat button reopens the pushed `ChatPage` route with the same target
|
|
||||||
|
|
||||||
This matches Discord's behavior when the member list collapses on resize.
|
|
||||||
|
|
||||||
## 6. Panel Sizing
|
|
||||||
|
|
||||||
| Element | Width | Behavior |
|
|
||||||
|---|---|---|
|
|
||||||
| VoicePanel (left) | 320dp fixed | VoiceBar, connection status, PTT controls. Unchanged. |
|
|
||||||
| Panel gaps | 12dp | Between each panel. Unchanged. |
|
|
||||||
| SnapshotView (center) | flex (1fr) | Grows to fill remaining space. |
|
|
||||||
| ChatPanel (right) | 380dp fixed | Only rendered at ≥1024dp. |
|
|
||||||
| Chat messages | max-width ~500dp | Centered within ChatPanel for readability. |
|
|
||||||
| macOS traffic light pad | 28dp top | Unchanged. Only affects height. |
|
|
||||||
|
|
||||||
**Center pane widths at common viewports:**
|
|
||||||
|
|
||||||
| Viewport | Center Width | Feel |
|
|
||||||
|---|---|---|
|
|
||||||
| 1024dp | 300dp | Minimum viable (matches Discord at same width) |
|
|
||||||
| 1200dp | 476dp | Comfortable |
|
|
||||||
| 1280dp | 556dp | Spacious (Chanora's default window size) |
|
|
||||||
| 1440dp | 716dp | Very spacious |
|
|
||||||
| 1920dp | 1184dp | Ultra-wide — consider capping center max-width post-MVP |
|
|
||||||
|
|
||||||
## 7. Migration Map
|
|
||||||
|
|
||||||
| File | Change | Scope |
|
|
||||||
|---|---|---|
|
|
||||||
| `lib/design/breakpoints.dart` | **New** — breakpoint tokens + `LayoutClass` enum | New file |
|
|
||||||
| `lib/design/viewport_info.dart` | **New** — `ViewportInfo` inherited widget | New file |
|
|
||||||
| `lib/main.dart` | Replace `_wideBreakpoint = 600.0` with `ChanoraBreakpoints.medium`. Wrap body in `ViewportInfo`. Add `_inlineChatTarget` state. Branch `_onOpenChat` for expanded vs compact/medium. Add `ChatPanel` to expanded Row. | Significant |
|
|
||||||
| `lib/widgets/chat_views.dart` | Replace `_chatMobileBreakpoint` with `ChanoraBreakpoints.medium`. No structural changes. | Token swap |
|
|
||||||
| `lib/widgets/connect_widgets.dart` | Replace hardcoded 400px with token. | Token swap |
|
|
||||||
| `lib/widgets/app_snack_bar.dart` | Replace hardcoded 600/560px with tokens. | Token swap |
|
|
||||||
| `lib/widgets/snapshot_view.dart` | No changes. Local spacer math stays local. | None |
|
|
||||||
| `lib/widgets/voice_compact.dart` | Replace platform branching with centralized helper (optional, post-MVP). | Optional |
|
|
||||||
|
|
||||||
**Unchanged:** macOS scaffold, ChanoraMobileScaffold, all voice controls, channel tree, chat route for compact/medium, all Rust bridge code.
|
|
||||||
|
|
||||||
## 8. Hardware Coverage
|
|
||||||
|
|
||||||
The 1024dp threshold coverage based on 2026 resolution data:
|
|
||||||
|
|
||||||
| Setup | Logical Width | Sees 3-Panel? |
|
|
||||||
|---|---|---|
|
|
||||||
| 1920×1080 @100% fullscreen | 1920dp | Yes |
|
|
||||||
| 1920×1080 @125% fullscreen | 1536dp | Yes |
|
|
||||||
| 1920×1080 @150% fullscreen | 1280dp | Yes |
|
|
||||||
| 1366×768 @100% fullscreen | 1366dp | Yes |
|
|
||||||
| 1366×768 @125% fullscreen | 1093dp | Yes |
|
|
||||||
| 1366×768 @125% windowed (~85%) | ~930dp | No (2-panel) |
|
|
||||||
| 2560×1440 @100% half-screen | ~1280dp | Yes |
|
|
||||||
| 2560×1440 @125% half-screen | ~1024dp | Yes (edge) |
|
|
||||||
| MacBook 13" Split View | ~708dp | No (2-panel) |
|
|
||||||
| MacBook 14" Split View | ~744dp | No (2-panel) |
|
|
||||||
| MacBook 16" Split View | ~852dp | No (2-panel) |
|
|
||||||
|
|
||||||
Chanora's default window (1280×720 on Windows/Linux) starts in 3-panel mode immediately.
|
|
||||||
|
|
||||||
## 9. Out of Scope (Post-MVP)
|
|
||||||
|
|
||||||
- Resizable panels (drag-to-resize VoicePanel/ChatPanel width)
|
|
||||||
- NavigationRail for ultra-wide monitors
|
|
||||||
- ChatPanel showing user profile or channel info
|
|
||||||
- Center pane max-width cap for ultra-wide monitors
|
|
||||||
- Centralized platform capability helper (consolidating voice_settings/voice_compact/audio_processing branching)
|
|
||||||
- Animated transitions between layout classes
|
|
||||||
- ChatPanel as a sheet/drawer on medium widths
|
|
||||||
|
|
||||||
## 10. References
|
|
||||||
|
|
||||||
- Discord member list collapse at 1024px: [compact-discord](https://github.com/asportnoy/compact-discord)
|
|
||||||
- Mattermost RHS persistent at ≥1024px: [structure.scss](https://github.com/mattermost/mattermost/blob/3440453d82613b1d8d67c93011c11d56a1380869/webapp/channels/src/sass/base/_structure.scss)
|
|
||||||
- Rocket.Chat contextual bar persistent at ≥1024px (lg breakpoint): [fuselage-tokens](https://github.com/RocketChat/fuselage/blob/ed91cb04db9fd6c35b43390190cbf7327c3eab9e/packages/fuselage-tokens/src/breakpoints.jsonc)
|
|
||||||
- Flutter AdaptiveScaffold discontinued: [flutter/flutter#162965](https://github.com/flutter/flutter/issues/162965)
|
|
||||||
- Material 3 canonical breakpoints: [m3.material.io/foundations/layout](https://m3.material.io/foundations/layout/breakpoints/overview)
|
|
||||||
- Chanora adaptive layout policy: [docs/ui-ux/adaptive-layout-platform-guide.md](../ui-ux/adaptive-layout-platform-guide.md)
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
# Maintainability Continuation Design
|
|
||||||
|
|
||||||
**Date:** 2026-06-08
|
|
||||||
**Status:** Approved design for implementation and full code-review remediation; Task 0 and focused audio-realtime fixes landed, documentation/governance alignment in progress
|
|
||||||
**Scope:** Continue the current working-branch maintainability pass, add full code-review findings, and fix high-risk bugs before broad rewrites.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This design continues the project review already present in the working tree. The goal is to simplify the project where changes are low-risk, testable, and documented, while avoiding speculative architecture churn.
|
|
||||||
|
|
||||||
The work covers unnecessary functions, structs, files, modules, duplicated custom implementations, built-in replacement opportunities, outdated documents, fail-safe gaps, Android runtime verification requirements, and full code-review remediation for hidden bugs.
|
|
||||||
|
|
||||||
## Recommended Approach
|
|
||||||
|
|
||||||
Use a targeted continuation of the current maintainability pass, now ordered by safety risk.
|
|
||||||
|
|
||||||
The existing branch already contains a first slice of simplification: core event DTO extraction, network diagnostics locality, `VecDeque` queue improvements, derived PTT backend errors, render downmix helper reuse, state reducer reuse, workspace metadata cleanup, and documentation updates. This design treats those changes as the baseline, but the full review found privacy, realtime-audio, disconnect, and documentation-governance issues that take priority over cosmetic simplification.
|
|
||||||
|
|
||||||
The remediation order is:
|
|
||||||
|
|
||||||
- Privacy and stuck-transmit fail-safes in Flutter voice state. Fixed in commit `d835394`.
|
|
||||||
- iOS audio-session error hardening before Rust VoiceProcessingIO startup. Missing-plugin/error handling fixed in commit `d835394`; iOS device runtime verification remains required.
|
|
||||||
- Rust realtime audio safety, especially unsynchronized render-reference buffers and blocking/allocating callbacks. Focused callback-path hardening fixed in commit `8606eb4`; full lock-free `AudioHandler` / config / debug-recorder redesign remains a follow-up.
|
|
||||||
- Bounded disconnect/control-plane progress in Rust protocol/core. Pending unless later code-review evidence closes it.
|
|
||||||
- Documentation and release/governance contradictions that can cause wrong verification claims. Addressed by Task 0.2 documentation alignment.
|
|
||||||
- Larger Module splits after behavior is protected by tests.
|
|
||||||
|
|
||||||
Rejected alternatives:
|
|
||||||
|
|
||||||
- Documentation-only audit: safer, but leaves clear simplifications unimplemented.
|
|
||||||
- Broad architectural cleanup: may produce long-term wins, but is too risky for this pass because bridge, protocol, audio, and Android behavior have high regression cost.
|
|
||||||
|
|
||||||
## Architecture Boundaries
|
|
||||||
|
|
||||||
The existing responsibilities remain intact:
|
|
||||||
|
|
||||||
- Flutter owns presentation, navigation, Material 3 behavior, accessibility, localization presentation, and platform UI behavior.
|
|
||||||
- Flutter Rust Bridge owns typed DTO/API glue and generated bindings.
|
|
||||||
- Rust Core owns session orchestration, cross-crate coordination, bridge-facing public events, and stable public APIs.
|
|
||||||
- Protocol owns TeamSpeak-compatible protocol isolation behind `tsclientlib`.
|
|
||||||
- Audio owns capture, render, processing, PTT backends, platform audio behavior, and voice packet handling where explicitly documented.
|
|
||||||
- Diagnostics owns redaction, logs, export records, and bounded diagnostic history.
|
|
||||||
|
|
||||||
Public interfaces should stay stable unless a change clearly removes duplicated or unnecessary code and has direct verification.
|
|
||||||
|
|
||||||
## Review Targets
|
|
||||||
|
|
||||||
The implementation review should inspect these areas first:
|
|
||||||
|
|
||||||
- `core/chanora_core/src/lib.rs`, `events.rs`, `network_diagnostics.rs`, and `ptt.rs`
|
|
||||||
- `crates/chanora_audio`, especially duplicated render, capture, PTT, and platform-audio helpers
|
|
||||||
- `crates/chanora_state` reducer paths
|
|
||||||
- `crates/chanora_protocol` adapter ordering, event, and DTO mapping paths
|
|
||||||
- `crates/chanora_bridge/src/api.rs`, excluding generated bridge files unless regeneration is intentionally part of a change
|
|
||||||
- `crates/chanora_diagnostics/src/lib.rs`
|
|
||||||
- `crates/chanora_prefetch` and `crates/chanora_resolver` as a documented follow-up seam decision unless a trivial cleanup appears
|
|
||||||
- `apps/chanora_flutter/lib`, excluding generated localization and bridge files unless an API change requires updates
|
|
||||||
- governance, architecture, implementation-status, and verification documents affected by the code review
|
|
||||||
|
|
||||||
Full code-review remediation targets:
|
|
||||||
|
|
||||||
- `apps/chanora_flutter/lib/main.dart`: mute ownership, iOS audio-session preflight, chat/unread follow-ups, and oversized session-controller extraction candidates.
|
|
||||||
- `apps/chanora_flutter/lib/widgets/voice_compact.dart`: touch PTT release-on-dispose fail-safe.
|
|
||||||
- `apps/chanora_flutter/lib/services/ios_audio_session_controller.dart`: missing-plugin fail-safe handling.
|
|
||||||
- `apps/chanora_flutter/lib/services/audio_lifecycle_service.dart`: macOS route/default-device no-op documentation or future adapter seam.
|
|
||||||
- `crates/chanora_audio/src/android_voice_unit.rs`, `ios_raw_unit.rs`, `ios_voice_unit.rs`, and `engine.rs`: realtime callback safety and platform lifecycle rollback.
|
|
||||||
- `core/chanora_core/src/lib.rs` and `crates/chanora_protocol/src/adapter.rs`: bounded disconnect and control-plane progress under voice load.
|
|
||||||
- `README.md`, `CHANGELOG.md`, `docs/release/*`, `docs/verification/*`, and `docs/governance/product-decision-register.md`: stale platform, release, VAD, Android runtime, and decision-register claims.
|
|
||||||
|
|
||||||
## Simplification Rules
|
|
||||||
|
|
||||||
Every code change must satisfy these rules:
|
|
||||||
|
|
||||||
- Prefer deletion, built-in APIs, derives, or reuse of existing helpers over new abstractions.
|
|
||||||
- Merge files or modules only when the merged unit has a clearer single responsibility.
|
|
||||||
- Split files only when it improves locality around a stable responsibility and preserves public API shape.
|
|
||||||
- Do not manually edit generated files unless the generation process is part of the verified change.
|
|
||||||
- Do not introduce backward-compatibility shims unless there is a persisted-data, shipped-API, external-consumer, or explicit product need.
|
|
||||||
- Record larger architectural opportunities in the maintainability review instead of forcing them into this pass.
|
|
||||||
|
|
||||||
Full code-review fix rules:
|
|
||||||
|
|
||||||
- Fix safety bugs before Module split work.
|
|
||||||
- Use test-driven development for production behavior changes: write the failing test, run it, implement the minimal fix, then rerun the test.
|
|
||||||
- Keep manual/generated bridge files out of direct edits unless regeneration is intentionally verified.
|
|
||||||
- Split huge Modules only when the split creates a deeper Module with leverage and locality; file-size-only sharding is not sufficient.
|
|
||||||
- Compare architecture choices against established voice/chat client practice: Mumble-style bounded voice/control separation, Discord/TeamSpeak-style independent mute owners, WebRTC-style realtime callback minimalism, and Matrix/Element-style coherent state replication.
|
|
||||||
|
|
||||||
## Testing Design
|
|
||||||
|
|
||||||
Verification is tied to change type:
|
|
||||||
|
|
||||||
- Rust-only changes require `cargo fmt --all`, `cargo check --workspace`, and `cargo test --workspace`.
|
|
||||||
- Flutter changes require `flutter analyze` and `flutter test --exclude-tags e2e` from `apps/chanora_flutter`.
|
|
||||||
- Bridge DTO/API changes require Rust verification, bridge generation check, Flutter analyze, and Flutter tests.
|
|
||||||
- Android platform, permission, lifecycle, or audio changes require Rust and Flutter verification plus Android NDK target compilation, `adb devices -l`, Android build/install, and a device or emulator smoke test.
|
|
||||||
- Documentation-only changes require affected docs and cross-links to be read and checked; code tests are not required unless the docs describe a code change just made.
|
|
||||||
|
|
||||||
If no ADB target is connected, Android runtime verification must be recorded as blocked. If Android target compilation cannot find the NDK compiler, for example `aarch64-linux-android-clang`, Android build evidence must also be recorded as blocked. The implementation must not claim Android runtime success without build/install/smoke evidence from an authorized device or emulator.
|
|
||||||
|
|
||||||
## Fail-Safe Review
|
|
||||||
|
|
||||||
The review must identify fail-safe gaps and either verify them, fix them, or record the missing evidence.
|
|
||||||
|
|
||||||
Priority fail-safe areas:
|
|
||||||
|
|
||||||
- User mute ownership must not be cleared by talk-power or permission recovery.
|
|
||||||
- Touch and keyboard PTT must release on cancellation, disposal, disconnect, lifecycle transition, or missed-up conditions.
|
|
||||||
- iOS AVAudioSession must be configured and activated before VoiceProcessingIO startup.
|
|
||||||
- Realtime callbacks must not block, allocate repeatedly, or use unsynchronized mutable aliasing.
|
|
||||||
- Disconnect and control requests must be bounded and must not hold global session locks across unbounded transport waits.
|
|
||||||
- Android and iOS device runtime behavior must be verified on hardware or an authorized emulator/simulator where applicable before platform success is claimed.
|
|
||||||
- Android secure storage and Keystore-backed data-encryption-key handling
|
|
||||||
- Android permission and audio lifecycle behavior
|
|
||||||
- Stuck PTT prevention and missed-key-up recovery
|
|
||||||
- Diagnostic redaction and privacy-sensitive event export
|
|
||||||
- Bridge DTO drift between Core, Bridge, and Dart generated bindings
|
|
||||||
- Protocol isolation exceptions for voice packet handling
|
|
||||||
- Runtime behavior gaps not covered by unit tests
|
|
||||||
|
|
||||||
No release-readiness or production-safety claim should be made without matching evidence.
|
|
||||||
|
|
||||||
## Documentation Design
|
|
||||||
|
|
||||||
The working review record remains `docs/governance/maintainability-review-2026-06-08.md`.
|
|
||||||
|
|
||||||
Documents to update when affected:
|
|
||||||
|
|
||||||
- `README.md`
|
|
||||||
- `CHANGELOG.md`
|
|
||||||
- `docs/governance/document-index.md`
|
|
||||||
- `docs/governance/product-decision-register.md`
|
|
||||||
- `docs/architecture/sad.md`
|
|
||||||
- `docs/architecture/sdd.md`
|
|
||||||
- `docs/implementation-status-2026-05-28.md`
|
|
||||||
- `docs/verification/swe4-unit-verification-plan.md`
|
|
||||||
- `docs/verification/swe5-software-integration-verification-plan.md`
|
|
||||||
- `docs/verification/verification-master-plan.md`
|
|
||||||
- `docs/verification/sys4-system-integration-verification-plan.md`
|
|
||||||
- `docs/release/release-readiness-go-nogo-record.md`
|
|
||||||
- `docs/release/dv-waiver-register.md`
|
|
||||||
- release or fail-safe records if verification status changes
|
|
||||||
|
|
||||||
Documentation should distinguish completed changes, follow-up opportunities, blocked verification, and release limitations.
|
|
||||||
|
|
||||||
## Commit Policy
|
|
||||||
|
|
||||||
No commit is created automatically. A commit happens only when explicitly requested, after inspecting `git status`, `git diff`, and recent commits.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
This work is successful when:
|
|
||||||
|
|
||||||
- Safe simplifications are implemented or recorded as follow-up opportunities.
|
|
||||||
- Built-in replacement opportunities are applied only when behavior remains covered by tests.
|
|
||||||
- Fail-safe gaps are documented with required evidence or fixed with verification.
|
|
||||||
- Rust and Flutter verification are run as required by the touched files, including targeted regression tests for every fixed bug.
|
|
||||||
- Android ADB runtime verification is run when a target is available or explicitly recorded as blocked.
|
|
||||||
- Documents reflect the final code and verification state.
|
|
||||||
- Full code-review findings are either fixed, downgraded with evidence, or recorded as follow-up risks with verification requirements.
|
|
||||||
+1
-1
@@ -1353,7 +1353,7 @@ External Server -> Protocol Adapter -> Rust Core -> State Engine -> Bridge -> Fl
|
|||||||
| Protocol compatibility | SE-05, SE-11 | `tsclientlib` adapter, protocol probe, compatibility matrix |
|
| Protocol compatibility | SE-05, SE-11 | `tsclientlib` adapter, protocol probe, compatibility matrix |
|
||||||
| Secure identity handling | SE-03, SE-16 | Platform secure storage, no plaintext private keys |
|
| Secure identity handling | SE-03, SE-16 | Platform secure storage, no plaintext private keys |
|
||||||
| Diagnostic privacy | SE-17, SE-19 | Redaction before export, user-initiated export |
|
| Diagnostic privacy | SE-17, SE-19 | Redaction before export, user-initiated export |
|
||||||
| Mobile foreground voice | SE-03, SE-14 | AVAudioSession (call-scoped VoIP activation with `.mixWithOthers`, idle `.ambient` baseline), Android foreground service, lifecycle handling |
|
| Mobile foreground voice | SE-03, SE-14 | AVAudioSession, Android foreground service, lifecycle handling |
|
||||||
| Release trust | SE-18 | Signing, notarization, app-store release metadata review |
|
| Release trust | SE-18 | Signing, notarization, app-store release metadata review |
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-10
@@ -500,7 +500,7 @@ External compatible server
|
|||||||
- Priority: P0
|
- Priority: P0
|
||||||
- Verification: Test
|
- Verification: Test
|
||||||
|
|
||||||
**SysRS-051**: The macOS runtime environment shall provide microphone permission prompts, microphone capture, speaker/headphone playback, audio device selection, and Keychain access to the Chanora application. Note: macOS has no AVAudioSession-equivalent surface; the macOS audio lifecycle in the current baseline is limited to launch-time microphone permission (`AVCaptureDevice.requestAccess`), the VPIO engine restart on Core Audio HAL default-device change (`kAudioHardwarePropertyDefaultInputDevice` / `DefaultOutputDevice` listeners), and the VPIO startup readback at engine start. Full audio route change + interruption handling — as available on iOS via AVAudioSession observers in `apps/chanora_flutter/ios/Runner/AppDelegate.swift` — is not present in the current macOS baseline; closing the gap is DEC-level scope, not a P0-MVP item.
|
**SysRS-051**: The macOS runtime environment shall provide microphone permission prompts, microphone capture, speaker/headphone playback, audio device selection, and Keychain access to the Chanora application.
|
||||||
|
|
||||||
- Priority: P0
|
- Priority: P0
|
||||||
- Verification: Test
|
- Verification: Test
|
||||||
@@ -1959,10 +1959,10 @@ This section converts the baseline product decisions into auditable system-level
|
|||||||
- Priority: P0
|
- Priority: P0
|
||||||
- Verification: Review (SysDes/SAD declaration of tolerance value is present and is referenced by the CI workflow definition)
|
- Verification: Review (SysDes/SAD declaration of tolerance value is present and is referenced by the CI workflow definition)
|
||||||
|
|
||||||
**SysRS-310**: The Chanora application system shall support macOS runtime deployment on macOS 13.0 (Ventura) or later as the minimum supported runtime, unless Flutter, plugin, audio, or platform constraints require raising the minimum version. This clause ratifies at the SysRS layer the existing macOS baseline already encoded in `apps/chanora_flutter/macos/chanora_bridge.podspec` (`MACOSX_DEPLOYMENT_TARGET = 13.0`) and provides explicit cross-platform-baseline coverage parallel to SysRS-286 (iOS) and SysRS-288 (Android). Rationale: macOS 13.0 is the floor that supports the modern CoreAudio / VoiceProcessingIO surfaces relied on by the realtime audio path, native arm64 Apple Silicon builds (no Rosetta dependence), and the SDK version used by the existing podspec lipo step that produces the universal binary; a lower minimum would require backporting audio code paths or shipping a non-universal build, neither of which is in P0-MVP scope. The minimum may be raised further (for example, to macOS 14 / Sonoma to gain the `kAUVoiceIOProperty_OtherAudioDuckingConfiguration` AudioUnit property) only via a superseding accepted decision recorded as a DEC entry; it shall not be lowered without a superseding accepted decision. Cross-references: SAD-087 (architectural macOS runtime allocation), SDD-119 (macOS bridge build pipeline consuming this baseline), SysRS-286 (iOS minimum runtime parallel), SysRS-288 (Android minimum runtime parallel). ID-allocation note: this clause uses SysRS-310 rather than the structurally parallel SysRS-290 because SysRS-290 is already allocated (MVP single-active-server-connection scope); monotonic numbering convention is preserved.
|
**SysRS-310**: The Chanora application system shall support macOS runtime deployment on macOS 10.15 (Catalina) or later as the minimum supported runtime, unless Flutter, plugin, audio, or platform constraints require raising the minimum version. This clause ratifies at the SysRS layer the existing macOS baseline already encoded in `apps/chanora_flutter/macos/chanora_bridge.podspec` (`MACOSX_DEPLOYMENT_TARGET = 10.15`) and provides explicit cross-platform-baseline coverage parallel to SysRS-286 (iOS) and SysRS-288 (Android). Rationale: macOS 10.15 is the floor that supports the modern CoreAudio / AVAudioEngine voice-processing-IO surfaces relied on by the realtime audio path, native arm64 Apple Silicon builds (no Rosetta dependence), and the SDK version used by the existing podspec lipo step that produces the universal binary; a lower minimum would require backporting audio code paths or shipping a non-universal build, neither of which is in P0-MVP scope. The minimum may be raised further (for example, to macOS 11.0 / Big Sur for native Apple Silicon performance gains) only via a superseding accepted decision recorded as a DEC entry; it shall not be lowered without a superseding accepted decision. Cross-references: SAD-087 (architectural macOS runtime allocation), SDD-119 (macOS bridge build pipeline consuming this baseline), SysRS-286 (iOS minimum runtime parallel), SysRS-288 (Android minimum runtime parallel). ID-allocation note: this clause uses SysRS-310 rather than the structurally parallel SysRS-290 because SysRS-290 is already allocated (MVP single-active-server-connection scope); monotonic numbering convention is preserved.
|
||||||
|
|
||||||
- Priority: P0
|
- Priority: P0
|
||||||
- Verification: Review (the SysRS baseline matches the podspec `MACOSX_DEPLOYMENT_TARGET` setting), Platform Test (the produced macOS binary runs on a macOS 13.0 system)
|
- Verification: Review (the SysRS baseline matches the podspec `MACOSX_DEPLOYMENT_TARGET` setting), Platform Test (the produced macOS binary runs on a macOS 10.15 system)
|
||||||
|
|
||||||
|
|
||||||
## 32. Change History Addendum
|
## 32. Change History Addendum
|
||||||
@@ -2005,10 +2005,3 @@ This section converts the baseline product decisions into auditable system-level
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 0.9.11 | 2026-05-18 | Closed the Wave 1.5 traceability-audit deferred-but-optional follow-up by adding SysRS-310 (macOS minimum runtime baseline at macOS 10.15 / Catalina), ratifying at SysRS layer the existing `apps/chanora_flutter/macos/chanora_bridge.podspec` `MACOSX_DEPLOYMENT_TARGET = 10.15` setting and providing explicit cross-platform-baseline coverage parallel to SysRS-286 (iOS) and SysRS-288 (Android). Allocated SysRS-310 rather than SysRS-290 because SysRS-290 is already taken (MVP single-active-server-connection scope); monotonic numbering convention preserved. No change to the numeric baseline value; any future raise (e.g., to macOS 11.0 / Big Sur for native Apple Silicon performance gains) is flagged as a DEC-level change, not authored here. |
|
| 0.9.11 | 2026-05-18 | Closed the Wave 1.5 traceability-audit deferred-but-optional follow-up by adding SysRS-310 (macOS minimum runtime baseline at macOS 10.15 / Catalina), ratifying at SysRS layer the existing `apps/chanora_flutter/macos/chanora_bridge.podspec` `MACOSX_DEPLOYMENT_TARGET = 10.15` setting and providing explicit cross-platform-baseline coverage parallel to SysRS-286 (iOS) and SysRS-288 (Android). Allocated SysRS-310 rather than SysRS-290 because SysRS-290 is already taken (MVP single-active-server-connection scope); monotonic numbering convention preserved. No change to the numeric baseline value; any future raise (e.g., to macOS 11.0 / Big Sur for native Apple Silicon performance gains) is flagged as a DEC-level change, not authored here. |
|
||||||
| 0.9.10 | 2026-05-18 | Authorized Option B of the benchmark-infrastructure decision for the realtime audio path. Added SysRS-307 (maintained numeric performance baselines for the realtime audio capture and playback paths — heap allocation count per callback after warmup, per-callback wall-clock budget relative to the audio frame period, Opus encode/decode latency, and resampler throughput at common rate-pair conversions; extends SysRS-180/SysRS-181 from prescriptive intent into a measurable contract; consistent with SysRS-186 and SysRS-236). Added SysRS-308 (advisory CI regression reporting executing the benchmark suite on every PR and every merge to the default branch on at least one host architecture, surfacing results in the PR status-check view; explicitly **advisory only** — does not fail the build, does not block merge; escalation to a build-failing hard gate is out of scope and deferred to a future SysRS clause after baseline maturity; consistent with the SysRS-234..239 verification-family pattern). Added SysRS-309 (explicitly declared numeric tolerance window for the advisory comparison; numeric value(s) and comparison methodology delegated to SysDes/SAD; suggested starting value +20% recorded as non-binding downstream guidance). All three new clauses are consistent with SysRS-295 (no automatic telemetry / diagnostic upload in MVP) — they neither authorize nor require off-device transmission of measurement data beyond the existing CI provider surface visible to repository collaborators. Explicitly **NOT** authored in this update: (a) Dimension 3 production telemetry export of timing histograms (deferred to P1; any future opt-in performance-evidence export through the user-initiated diagnostic-export path requires a separate P1 SysRS clause and must be reconciled with SysRS-295 at that time); (b) build-failing hard CI gate (deferred to a future SysRS clause). |
|
| 0.9.10 | 2026-05-18 | Authorized Option B of the benchmark-infrastructure decision for the realtime audio path. Added SysRS-307 (maintained numeric performance baselines for the realtime audio capture and playback paths — heap allocation count per callback after warmup, per-callback wall-clock budget relative to the audio frame period, Opus encode/decode latency, and resampler throughput at common rate-pair conversions; extends SysRS-180/SysRS-181 from prescriptive intent into a measurable contract; consistent with SysRS-186 and SysRS-236). Added SysRS-308 (advisory CI regression reporting executing the benchmark suite on every PR and every merge to the default branch on at least one host architecture, surfacing results in the PR status-check view; explicitly **advisory only** — does not fail the build, does not block merge; escalation to a build-failing hard gate is out of scope and deferred to a future SysRS clause after baseline maturity; consistent with the SysRS-234..239 verification-family pattern). Added SysRS-309 (explicitly declared numeric tolerance window for the advisory comparison; numeric value(s) and comparison methodology delegated to SysDes/SAD; suggested starting value +20% recorded as non-binding downstream guidance). All three new clauses are consistent with SysRS-295 (no automatic telemetry / diagnostic upload in MVP) — they neither authorize nor require off-device transmission of measurement data beyond the existing CI provider surface visible to repository collaborators. Explicitly **NOT** authored in this update: (a) Dimension 3 production telemetry export of timing histograms (deferred to P1; any future opt-in performance-evidence export through the user-initiated diagnostic-export path requires a separate P1 SysRS clause and must be reconciled with SysRS-295 at that time); (b) build-failing hard CI gate (deferred to a future SysRS clause). |
|
||||||
|
|
||||||
|
|
||||||
## Baseline Candidate 0.9.12 Update
|
|
||||||
|
|
||||||
| Version | Date | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| 0.9.12 | 2026-06-07 | Raised the macOS minimum runtime baseline in `SysRS-310` from macOS 10.15 (Catalina) to macOS 13.0 (Ventura) to match the actual floor encoded in `apps/chanora_flutter/macos/chanora_bridge.podspec` (`MACOSX_DEPLOYMENT_TARGET = 13.0`) and `apps/chanora_flutter/macos/macos_deployment_target.rb`. The previous 10.15 text was a documentation lag; no behavioural change, no DEC-level raise, and the podspec + Xcode project are unchanged. macOS 13.0 is the floor that supports native arm64 Apple Silicon without Rosetta, modern CoreAudio / VoiceProcessingIO surfaces, and the podspec lipo step that produces the universal binary. Strict layered sourcing preserved (`SysDes -> SysRS` only). |
|
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ This plan defines unit verification coverage for the software modules that imple
|
|||||||
| `flutter analyze` in `apps/chanora_flutter` | Software | Dart static analysis |
|
| `flutter analyze` in `apps/chanora_flutter` | Software | Dart static analysis |
|
||||||
| `flutter test --exclude-tags e2e` in `apps/chanora_flutter` | Software / QA | Flutter unit and widget tests |
|
| `flutter test --exclude-tags e2e` in `apps/chanora_flutter` | Software / QA | Flutter unit and widget tests |
|
||||||
|
|
||||||
For local code-change reviews, a Rust change is not complete until `cargo fmt --all`, `cargo check --workspace`, and `cargo test --workspace` have been run fresh and read for failures. Flutter or bridge changes additionally require Flutter analysis/tests.
|
|
||||||
|
|
||||||
## 4. SRS Unit Coverage Focus
|
## 4. SRS Unit Coverage Focus
|
||||||
|
|
||||||
| SRS group | Unit focus | Status for DV |
|
| SRS group | Unit focus | Status for DV |
|
||||||
@@ -53,7 +51,6 @@ For local code-change reviews, a Rust change is not complete until `cargo fmt --
|
|||||||
| Event replay tool not found | P1 verification hook is incomplete | Mark deferred/P1 in SWE.6 and waiver register if discussed |
|
| Event replay tool not found | P1 verification hook is incomplete | Mark deferred/P1 in SWE.6 and waiver register if discussed |
|
||||||
| Platform keyring behavior not exercised in CI | Secure-storage unit evidence is incomplete for real OS services | Cover through platform audit/SYS.4, not CI-only claims |
|
| Platform keyring behavior not exercised in CI | Secure-storage unit evidence is incomplete for real OS services | Cover through platform audit/SYS.4, not CI-only claims |
|
||||||
| Audio device hot-plug recovery follow-up exists | Limits reliability evidence for SRS-082 | Mark as P1 gap |
|
| Audio device hot-plug recovery follow-up exists | Limits reliability evidence for SRS-082 | Mark as P1 gap |
|
||||||
| Android runtime smoke cannot run without attached target | Rust unit tests do not exercise Android permission/audio/lifecycle fail-safes | `adb devices -l` must show a device or emulator before Android runtime success can be claimed |
|
|
||||||
|
|
||||||
## 6. SWE.4 DV Decision Rule
|
## 6. SWE.4 DV Decision Rule
|
||||||
|
|
||||||
|
|||||||
@@ -52,20 +52,7 @@ An integration path passes when:
|
|||||||
| iOS build is unsigned | Blocks App Store/TestFlight release approval | Release record must state unsigned verification only |
|
| iOS build is unsigned | Blocks App Store/TestFlight release approval | Release record must state unsigned verification only |
|
||||||
| Android Keystore-backed DEK deferred | Limits secure-storage integration claim on Android | Waiver required for internal testing; public release claim blocked |
|
| Android Keystore-backed DEK deferred | Limits secure-storage integration claim on Android | Waiver required for internal testing; public release claim blocked |
|
||||||
| Event replay infrastructure not found | Limits protocol-state integration stress evidence | Mark P1 gap |
|
| Event replay infrastructure not found | Limits protocol-state integration stress evidence | Mark P1 gap |
|
||||||
| Android target compile/runtime blocked during local review | Missing NDK compiler `aarch64-linux-android-clang` and no attached authorized target block device/emulator verification of Android audio, permission, lifecycle, and storage fail-safe behaviour | Fix/install the NDK toolchain, connect a device/emulator, confirm with `adb devices -l`, then run Android build/install/smoke before claiming Android success |
|
|
||||||
|
|
||||||
## 6. Android Runtime Verification Gate
|
## 6. SWE.5 DV Decision Rule
|
||||||
|
|
||||||
Android integration changes require a working Android target toolchain and a connected device or emulator. The minimum local gate is:
|
|
||||||
|
|
||||||
1. The Android NDK compiler needed by the target build, including `aarch64-linux-android-clang` for arm64, is available.
|
|
||||||
2. `adb devices -l` shows one authorized target.
|
|
||||||
3. Android app builds for that target.
|
|
||||||
4. The app installs and launches.
|
|
||||||
5. Permission, connect-screen, audio-start/stop, and diagnostic-export smoke paths are exercised or explicitly marked not applicable to the change.
|
|
||||||
|
|
||||||
If the NDK compiler is unavailable or no target is connected and authorized, Android verification is blocked rather than passed.
|
|
||||||
|
|
||||||
## 7. SWE.5 DV Decision Rule
|
|
||||||
|
|
||||||
SWE.5 can pass for DV documentation readiness if every integration path has an identified evidence method and every incomplete path has a waiver or follow-up. SWE.5 does not pass for public release until current candidate integration runs are attached to the release-readiness record.
|
SWE.5 can pass for DV documentation readiness if every integration path has an identified evidence method and every incomplete path has a waiver or follow-up. SWE.5 does not pass for public release until current candidate integration runs are attached to the release-readiness record.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ This plan verifies Chanora as an integrated application system, including softwa
|
|||||||
|
|
||||||
| Platform | Current candidate status | Required SYS.4 evidence before public/store release |
|
| Platform | Current candidate status | Required SYS.4 evidence before public/store release |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Android | Implemented features include permissions, foreground service, Oboe audio, MODE_IN_COMMUNICATION; Android Keystore-backed DEK deferred; local target compile/runtime smoke blocked by missing `aarch64-linux-android-clang` and no authorized ADB target | NDK target compilation, `adb devices -l` authorized target evidence, device smoke, permission flow, foreground voice, secure-storage limitation waiver, Play target SDK inspection |
|
| Android | Implemented features include permissions, foreground service, Oboe audio, MODE_IN_COMMUNICATION; Android Keystore-backed DEK deferred | Device smoke, permission flow, foreground voice, secure-storage limitation waiver, Play target SDK inspection |
|
||||||
| iOS | Source-buildable and unsigned; AVAudioSession work exists; public artifact not ready | Unsigned build evidence, device audio session smoke, signing/TestFlight evidence before release |
|
| iOS | Source-buildable and unsigned; AVAudioSession work exists; public artifact not ready | Unsigned build evidence, device audio session smoke, signing/TestFlight evidence before release |
|
||||||
| Windows | Source-buildable; smoke procedure exists | Executed smoke result, PTT capability evidence, packaging/signing evidence before release |
|
| Windows | Source-buildable; smoke procedure exists | Executed smoke result, PTT capability evidence, packaging/signing evidence before release |
|
||||||
| macOS | Source-buildable; not in current release artifacts | Build evidence, PTT capability evidence, signing/notarization evidence before release |
|
| macOS | Source-buildable; not in current release artifacts | Build evidence, PTT capability evidence, signing/notarization evidence before release |
|
||||||
@@ -51,7 +51,6 @@ This plan verifies Chanora as an integrated application system, including softwa
|
|||||||
| Security/privacy/legal readiness | DEC-012 and privacy/security gates are signed off or explicitly block release |
|
| Security/privacy/legal readiness | DEC-012 and privacy/security gates are signed off or explicitly block release |
|
||||||
| Release artifact integrity | Build number, commit SHA, tag, artifact hashes, and signing status are recorded |
|
| Release artifact integrity | Build number, commit SHA, tag, artifact hashes, and signing status are recorded |
|
||||||
| Environmental limitations | Source-build-only, unsigned, or platform-specific limitations are listed in release readiness |
|
| Environmental limitations | Source-build-only, unsigned, or platform-specific limitations are listed in release readiness |
|
||||||
| Android runtime claim control | No Android runtime, permission-flow, or audio-lifecycle success is claimed until target compile, install, and device/emulator smoke evidence are attached |
|
|
||||||
|
|
||||||
## 6. SYS.4 DV Decision Rule
|
## 6. SYS.4 DV Decision Rule
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user