Compare commits

...
Author SHA1 Message Date
Edison Jwa 861e915b81 chore(repo): untrack macOS chanora_bridge.framework build artifacts
The macOS chanora_bridge.framework tree at
apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework was
being tracked in git despite being a pure build artifact. Both
mechanisms in chanora_bridge.podspec rebuild the entire tree from
scratch:

  * prepare_command (runs on `pod install`) — `rm -rf $FW` and
    reconstructs Versions/A, the Versions/Current and Resources
    symlinks, Info.plist, and copies the lipo-merged universal dylib.
  * script_phase :before_compile (runs on every Xcode build) — same
    rm -rf + reconstruction, gated on freshness of the cargo output.

Tracking the tree therefore added zero value and ~36 MB per binary
revision (the chanora_bridge dylib alone). The iOS counterpart at
apps/chanora_flutter/ios/Frameworks/ has been correctly ignored since
.gitignore:118-119 was added; this commit mirrors that rule for macOS.

Changes:
  - git rm --cached -r the 5 tracked entries (binary, 3 symlinks,
    Info.plist). Working tree is untouched, so existing local
    builds keep functioning until the next `pod install` /
    Xcode build refreshes them.
  - Add /apps/chanora_flutter/macos/Frameworks/ to .gitignore
    alongside the existing iOS entry, with a comment pointing at the
    podspec mechanism so the next maintainer understands the rule.

Verified the working tree binary survives the cache untrack and
the path is now matched by .gitignore:125.
2026-06-07 20:55:41 +09:00
Edison Jwa 5f1423c349 feat(voice): unified mobile voice bar with gesture-isolated PTT row (#22)
* feat(voice): unified mobile voice bar with gesture-isolated PTT row

Replace separate VoiceStatusChip + VoicePttButton with a single
CompactVoiceBar widget that combines both into a two-row layout:

- Control row (tap): status text, mute, deafen, settings chevron
- PTT row (hold): full-width hold-to-talk, shown only in PTT mode

Gesture isolation prevents mis-touch between rows: the control row
uses tap-only InkWell/IconButton while the PTT row uses a raw
Listener for pointer-down/up events.

Key changes:
- Add CompactVoiceBar widget with state-colored container (normal,
  muted, talk-power-blocked)
- Remove mute/deafen IconButtons from AppBar headerActions
- Restructure voice details sheet into primary section + collapsible
  ExpansionTiles (audio processing, PTT capability, debug)
- Optimistic state updates for mute/deafen to eliminate tap delay
- Instant PTT visual feedback (no AnimatedContainer fade)
- Constant geometry across all states (no layout shift on toggle)

* fix(voice): preserve current PTT button format

* feat(voice): move mute/deafen controls into VoiceStatusChip

* fix(voice): ensure consistent chip height across mute states

Remove isSelected/selectedIcon from IconButtons inside VoiceStatusChip.
Material 3 toggle IconButtons (_SelectableIconButton) can vary in height
when the selected state changes due to tap target sizing. Use simple
conditional icons instead and set shrinkWrap tap target size with tight
constraints for stable 40x40 buttons regardless of state.

* fix(voice): remove leftover duplicate mute/deafen buttons in VoiceStatusChip

* fix(voice): replace unsafe stereo cast with bytemuck and localise talk-power tooltip

Replace the raw-pointer `&mut [(f32, f32)]` to `&mut [f32]` cast in
the oboe output callback with `bytemuck::cast_slice_mut`, eliminating
the unsafe block and relying on bytemuck compile-time NoUninit
verification instead.

Add voiceTalkPowerBlocked l10n key (en + zh) and replace the only
remaining hard-coded English tooltip in VoiceStatusChip with it.
2026-06-05 20:58:16 +09:00
Edison Jwa 82441f3d97 feat(voice): real-time mic input level metering at 30 Hz (#25)
* feat(voice): add real-time mic input level metering at 30 Hz

Expose input RMS from the audio engine through the bridge as a
dedicated Rust→Dart Stream<double>, replacing the binary on/off
indicator with a proportional dBFS level meter.

Rust side:
- chanora_audio: add set_input_dbfs/input_dbfs accessors to
  SharedAudioProcessingStats; restructure CaptureState::ingest()
  to compute dBFS from mono buffer before the PTT guard so the
  meter shows mic activity even when not transmitting.
- chanora_core: widen audio_stats() return to include f32 input
  level.
- chanora_bridge: add input_level: f32 to BridgeAudioStats and
  new input_level_stream(sink: StreamSink<f32>) that pushes at
  ~30 Hz via tokio interval task.
- Update frb_generated.rs serialization for the new field.

Flutter side:
- VoiceLevelMeter: accept optional double level (dBFS), map
  -60..0 dBFS to 0..1 fill fraction, animate with
  TweenAnimationBuilder for smooth transitions.
- voice_compact.dart: subscribe to inputLevelStream in the voice
  details sheet for 30 Hz meter updates, keeping 250 ms poll for
  TX/RX counters.
- voice_bar.dart: accept optional inputLevel from the stream.
- main.dart: subscribe to inputLevelStream, pass to VoiceBar.

* chore: sync Flutter build config and dependency updates

- Add Flutter migrator flags to gradle.properties (builtInKotlin, newDsl)
- Add FlutterGeneratedPluginSwiftPackage to iOS/macOS Xcode projects
- Update meta 1.17→1.18, test_api 0.7.10→0.7.11
- Rebuild chanora_bridge framework for macOS
- Update Podfile.lock for iOS and macOS

* fix(voice): correct meter animation, pre-gain dBFS, stream lifecycle, and protocol warnings

B1: Convert VoiceLevelMeter to StatefulWidget tracking previous fill
     as Tween begin so the meter animates smoothly instead of resetting
     to zero on every frame.

B2: Compute dBFS from pre-gain mono samples in CaptureState::ingest()
     so the level meter reflects raw mic input, matching mobile paths.

B4: End input_level_stream after 10 consecutive session errors instead
     of emitting -120 dBFS forever when the session is gone.

Also fixes all 13 clippy warnings in chanora_protocol: collapsed
nested if-let patterns, replaced .ok() + Some matching with Ok, used
? operator, and introduced EventChannels struct to reduce the four
helper functions below the 7-argument threshold.

* fix(voice): use MissedTickBehavior::Skip for level meter stream and align dBFS doc

Set MissedTickBehavior::Skip on the input_level_stream tokio interval
so slow audio_stats() calls skip missed ticks instead of bursting,
preventing CPU spikes on the UI meter thread.

Align VoiceLevelMeter class doc: the mapping floors at -60 dBFS
(via dbfsToFraction), not the full -120 range.
2026-06-05 20:57:16 +09:00
Edison Jwa 2c7b68e21e chore(android): upgrade toolchain to AGP 8.13.1 / Kotlin 2.3.0 (#23)
Bump Android Gradle Plugin from 8.11.1 to 8.13.1 and Kotlin from
2.2.20 to 2.3.0 to align with newer plugin version requirements.

Kotlin 2.3.0 removed the kotlinOptions DSL free-string assignment.
Migrate to the compilerOptions DSL for JVM target configuration.

Gradle wrapper remains at 8.14 (compatible with AGP 8.13.1).
2026-06-05 20:54:51 +09:00
Edison Jwa 12e3a1f4ee feat(macos): add macOS permissions service for Input Monitoring, Local Network, and Notifications (#21)
* feat(macos): add macOS permissions service for Input Monitoring, Local Network, and Notifications

Add MacOSPermissionsService (Dart) + native MethodChannel handler (Swift)
for macOS-specific permissions not covered by permission_handler:

- Input Monitoring (CGPreflightListenEventAccess /
  CGRequestListenEventAccess) for global PTT via Event Tap
- Local Network Privacy prompt (NWBrowser for _ts3._tcp, macOS 15+)
- Notifications (UNUserNotificationCenter authorization)

Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091

Changes:
- Info.plist: add NSBonjourServices array with _ts3._tcp
- macos_permissions_service.dart: Dart service with MethodChannel,
  ValueNotifier states, PTT capability derivation (L0Focused /
  L1MacOSEventTap), non-macOS short-circuit
- MainFlutterWindow.swift: native handler registered as FlutterPlugin,
  Input Monitoring check/request/polling, NWBrowser trigger with
  denial detection, UNUserNotificationCenter request
- main.dart: wire service into bootstrap lifecycle, listen for PTT
  capability changes from Input Monitoring state
- macos_permissions_service_test.dart: 17 unit tests covering inbound
  state changes, outbound calls, lifecycle, error handling, platform
  behavior (179/179 full suite pass)

* fix(macos): keep permissions capability state live
2026-06-05 14:26:11 +09:00
Edison Jwa 2902a8bcd5 fix(audio): eliminate Android output stutter via Oboe config + lock-free callback (#20)
* fix(audio): eliminate Android output stutter via Oboe config + lock-free callback

Phase 1 — Oboe configuration:
- Change output stream from Usage::VoiceCommunication to Usage::Game with
  ContentType::Sonification to avoid forcing the Legacy (OpenSL ES) data
  path on most devices (Oboe issue #2075)
- Switch output format from i16 Mono to f32 Stereo, matching Qint's proven
  configuration and eliminating per-callback downmix conversion
- Set buffer size to 2x burst after stream open, reducing default buffer
  from 8-20x burst to 2x burst for lower latency
- Remove scratch Mutex<Vec<f32>>; callback writes directly to Oboe buffer

Phase 2 — Lock-free output callback:
- Add audio_event_queue.rs: lock-free SPSC bridge using crossbeam ArrayQueue
  with separate packet (lossy) and control (reliable) channels
- OutputCallback now owns AudioHandler directly (no Arc<Mutex<>> on Android)
- Inbound forwarder pushes packets via AudioEventProducer (no mutex)
- set_client_volume pushes control commands via event queue on Android
- iOS/desktop Arc<Mutex<AudioHandler>> path unchanged

* fix(audio): address PR #20 review findings

- Store AudioEventConsumer directly in OutputCallback to eliminate
  per-callback Arc clone on the real-time audio thread
- Add SAFETY comment for the unsafe from_raw_parts_mut transmute
- Bound set_client_volume spin-loop to 64 retries with warn log
- Remove redundant crossbeam-utils direct dependency
- Regenerate license inventory for new crossbeam deps (CI fix)

* fix(audio): use ASCII TODO punctuation
2026-06-05 13:57:53 +09:00
Edison Jwa fb2a8e0a80 docs(rust): add doc comments to delta enums and fix dead_code warnings (#24)
* docs(rust): add doc comments to delta enums and fix dead_code warnings

Add missing documentation to ProtocolDelta, CoreDelta, and BridgeDelta
enum variants and their struct fields across the protocol, core, and
bridge crates. Document the ChannelId::ROOT constant and the
take_delta_rx adapter method.

Fix dead_code warnings:
- keyring_disabled: add #[cfg] gate matching its callers
- snapshot_signature: add #[cfg(test)] for future test use

* fix(rust): correct `order` field docs to predecessor channel ID, narrow audio engine cfg gates

- Correct `order` field documentation in ProtocolDelta, SessionEvent,
  and BridgeEvent from 'sort order' to 'predecessor channel ID
  (TeamSpeak linked-list ordering hint)' per Copilot review feedback.
- Narrow AudioEngine voice_out_tx, voice_activity_selector, and mic_gain
  cfg gates from ios+macos+android to android-only, since these fields
  are only read from self in android_restart_voice_unit. On iOS/macOS the
  values are passed directly to the voice backend at construction time.
2026-06-05 11:54:59 +09:00
Edison Jwa d3bb199208 ci: add opencode GitHub Actions workflow 2026-06-03 19:28:06 +09:00
50 changed files with 2757 additions and 474 deletions
+33
View File
@@ -0,0 +1,33 @@
name: opencode
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
opencode:
if: |
contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: read
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
with:
model: zhipuai-coding-plan/glm-5.1
+6
View File
@@ -117,6 +117,12 @@ opencode.json
# iOS framework build artifacts produced by chanora_bridge.podspec # iOS framework build artifacts produced by chanora_bridge.podspec
/apps/chanora_flutter/ios/Frameworks/ /apps/chanora_flutter/ios/Frameworks/
# macOS framework build artifacts produced by chanora_bridge.podspec
# (prepare_command + script_phase rm -rf and regenerate this tree on
# every pod install AND every Xcode build, so tracking it in git is
# pure waste — the committed binary was ~40 MB per commit).
/apps/chanora_flutter/macos/Frameworks/
.opencode/ .opencode/
AGENTS.md AGENTS.md
Screenshot 2026-05-17 at 22.23.07.png Screenshot 2026-05-17 at 22.23.07.png
Generated
+22
View File
@@ -419,10 +419,12 @@ name = "chanora_audio"
version = "0.2.0-beta.1" version = "0.2.0-beta.1"
dependencies = [ dependencies = [
"audiopus", "audiopus",
"bytemuck",
"chanora_protocol", "chanora_protocol",
"coreaudio-rs", "coreaudio-rs",
"cpal", "cpal",
"criterion", "criterion",
"crossbeam",
"dhat", "dhat",
"dispatch2", "dispatch2",
"futures-util", "futures-util",
@@ -803,6 +805,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
dependencies = [
"crossbeam-epoch",
"crossbeam-queue",
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-channel" name = "crossbeam-channel"
version = "0.5.15" version = "0.5.15"
@@ -831,6 +844,15 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@@ -63,8 +63,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlin {
jvmTarget = JavaVersion.VERSION_17.toString() compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
} }
defaultConfig { defaultConfig {
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.13.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.android") version "2.3.0" apply false
} }
include(":app") include(":app")
-37
View File
@@ -1,69 +1,32 @@
PODS: PODS:
- audio_session (0.0.1):
- Flutter
- chanora_bridge (1.0.0) - chanora_bridge (1.0.0)
- connectivity_plus (0.0.1):
- Flutter
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_foreground_task (0.0.1): - flutter_foreground_task (0.0.1):
- Flutter - Flutter
- haptic_kit (1.0.0): - haptic_kit (1.0.0):
- Flutter - Flutter
- package_info_plus (0.4.5):
- Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
DEPENDENCIES: DEPENDENCIES:
- audio_session (from `.symlinks/plugins/audio_session/ios`)
- chanora_bridge (from `.`) - chanora_bridge (from `.`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`) - flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
- haptic_kit (from `.symlinks/plugins/haptic_kit/ios`) - haptic_kit (from `.symlinks/plugins/haptic_kit/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
EXTERNAL SOURCES: EXTERNAL SOURCES:
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
chanora_bridge: chanora_bridge:
:path: "." :path: "."
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_foreground_task: flutter_foreground_task:
:path: ".symlinks/plugins/flutter_foreground_task/ios" :path: ".symlinks/plugins/flutter_foreground_task/ios"
haptic_kit: haptic_kit:
:path: ".symlinks/plugins/haptic_kit/ios" :path: ".symlinks/plugins/haptic_kit/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
chanora_bridge: 2ed7c2ba427fab135dd9eab66c507b09cfee113a chanora_bridge: 2ed7c2ba427fab135dd9eab66c507b09cfee113a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89 flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758 haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
PODFILE CHECKSUM: e2123068539aeb66d53dc1612b383d13f489ede2 PODFILE CHECKSUM: e2123068539aeb66d53dc1612b383d13f489ede2
@@ -20,6 +20,7 @@
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 */; };
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 */
@@ -72,6 +73,7 @@
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>"; };
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>"; };
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>"; };
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 */
@@ -79,6 +81,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */, 8C5000042DD0000000000001 /* SileroCoreML in Frameworks */,
1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */, 1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */,
); );
@@ -128,6 +131,7 @@
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */, 9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -216,6 +220,7 @@
); );
name = Runner; name = Runner;
packageProductDependencies = ( packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
8C5000032DD0000000000001 /* SileroCoreML */, 8C5000032DD0000000000001 /* SileroCoreML */,
); );
productName = Runner; productName = Runner;
@@ -252,6 +257,7 @@
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
packageReferences = ( packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */, 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
); );
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
@@ -760,6 +766,10 @@
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 */
@@ -768,6 +778,10 @@
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 */;
@@ -5,6 +5,24 @@
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
+2 -1
View File
@@ -298,5 +298,6 @@
"clientVolumeMuteAction": "Mute user", "clientVolumeMuteAction": "Mute user",
"clientVolumeUnmuteAction": "Unmute user", "clientVolumeUnmuteAction": "Unmute user",
"clientVolumeResetAction": "Reset to default", "clientVolumeResetAction": "Reset to default",
"permissionDenied": "Permission Denied" "permissionDenied": "Permission Denied",
"voiceTalkPowerBlocked": "Insufficient talk power to speak in this channel"
} }
+2 -1
View File
@@ -241,5 +241,6 @@
"clientVolumeMuteAction": "静音该用户", "clientVolumeMuteAction": "静音该用户",
"clientVolumeUnmuteAction": "取消静音", "clientVolumeUnmuteAction": "取消静音",
"clientVolumeResetAction": "恢复默认", "clientVolumeResetAction": "恢复默认",
"permissionDenied": "权限被拒绝" "permissionDenied": "权限被拒绝",
"voiceTalkPowerBlocked": "发言权限不足,无法在此频道发言"
} }
@@ -1246,6 +1246,12 @@ abstract class AppL10n {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Permission Denied'** /// **'Permission Denied'**
String get permissionDenied; String get permissionDenied;
/// No description provided for @voiceTalkPowerBlocked.
///
/// In en, this message translates to:
/// **'Insufficient talk power to speak in this channel'**
String get voiceTalkPowerBlocked;
} }
class _AppL10nDelegate extends LocalizationsDelegate<AppL10n> { class _AppL10nDelegate extends LocalizationsDelegate<AppL10n> {
@@ -653,4 +653,8 @@ class AppL10nEn extends AppL10n {
@override @override
String get permissionDenied => 'Permission Denied'; String get permissionDenied => 'Permission Denied';
@override
String get voiceTalkPowerBlocked =>
'Insufficient talk power to speak in this channel';
} }
@@ -641,4 +641,7 @@ class AppL10nZh extends AppL10n {
@override @override
String get permissionDenied => '权限被拒绝'; String get permissionDenied => '权限被拒绝';
@override
String get voiceTalkPowerBlocked => '发言权限不足,无法在此频道发言';
} }
+130 -42
View File
@@ -23,6 +23,7 @@ import 'services/audio_lifecycle_service.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/ios_permissions_service.dart'; import 'services/ios_permissions_service.dart';
import 'services/macos_permissions_service.dart';
import 'services/prefetch_debouncer.dart'; import 'services/prefetch_debouncer.dart';
import 'services/snapshot_state_mapper.dart'; import 'services/snapshot_state_mapper.dart';
import 'services/ts3_server_link.dart'; import 'services/ts3_server_link.dart';
@@ -311,6 +312,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
rust.BridgeSnapshot? _snapshot; rust.BridgeSnapshot? _snapshot;
final List<String> _uiDiagnostics = []; final List<String> _uiDiagnostics = [];
rust.BridgeAudioStats? _audioStats; rust.BridgeAudioStats? _audioStats;
double? _inputLevel;
Timer? _statsTimer; Timer? _statsTimer;
bool _snapshotRefreshInFlight = false; bool _snapshotRefreshInFlight = false;
bool _snapshotRefreshQueued = false; bool _snapshotRefreshQueued = false;
@@ -318,6 +320,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
bool _snapshotRefreshQueuedReportErrors = false; bool _snapshotRefreshQueuedReportErrors = false;
int _connectionEpoch = 0; int _connectionEpoch = 0;
StreamSubscription<rust.BridgeEvent>? _eventsSub; StreamSubscription<rust.BridgeEvent>? _eventsSub;
StreamSubscription<double>? _inputLevelSub;
late final PrefetchDebouncer _prefetch; late final PrefetchDebouncer _prefetch;
// v1 voice subsystem state (SDD-094/095/096/097). Driven by // v1 voice subsystem state (SDD-094/095/096/097). Driven by
@@ -378,6 +381,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final AndroidPermissionsService _androidPermissions = final AndroidPermissionsService _androidPermissions =
AndroidPermissionsService(); AndroidPermissionsService();
final IosPermissionsService _iosPermissions = IosPermissionsService(); final IosPermissionsService _iosPermissions = IosPermissionsService();
// SRS-198 / SRS-297 / SRS-300: macOS Input Monitoring, Local Network,
// and Notifications permission service. On non-macOS hosts the service
// short-circuits to "granted" / "unsupported" and never wires the
// MethodChannel.
final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService();
final UiPreferencesService _uiPreferences = const UiPreferencesService(); final UiPreferencesService _uiPreferences = const UiPreferencesService();
@override @override
@@ -388,6 +396,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_prefetch = PrefetchDebouncer(onPrefetch: _onPrefetchServer); _prefetch = PrefetchDebouncer(onPrefetch: _onPrefetchServer);
_hostCtl.addListener(_onHostEdited); _hostCtl.addListener(_onHostEdited);
_eventsSub = rust.eventsStream().listen(_onEvent); _eventsSub = rust.eventsStream().listen(_onEvent);
_inputLevelSub = rust.inputLevelStream().listen((level) {
if (mounted) setState(() => _inputLevel = level);
});
// SDD-106 §5: subscribe to Kotlin -> Dart permissionStateChanged // SDD-106 §5: subscribe to Kotlin -> Dart permissionStateChanged
// events as early as possible so the listen-only banner reflects // events as early as possible so the listen-only banner reflects
// the system state on first frame. // the system state on first frame.
@@ -399,6 +410,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_iosPermissions.recordAudioState.addListener( _iosPermissions.recordAudioState.addListener(
_onRecordAudioPermissionChanged, _onRecordAudioPermissionChanged,
); );
// SRS-198 / SRS-297 / SRS-300: start macOS permission service.
// On non-macOS this is a no-op. On macOS, it checks Input
// Monitoring state and begins polling for changes so the PTT
// capability badge upgrades from L0Focused → L1MacOSEventTap
// when the user grants the permission in System Settings.
_macOSPermissions.start();
_macOSPermissions.pttCapabilityState.addListener(
_onMacOSPttCapabilityChanged,
);
_macOSPermissions.checkInitialStates();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_requestRecordAudioOnStartup()); unawaited(_requestRecordAudioOnStartup());
}); });
@@ -498,6 +519,28 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
} }
} }
/// SRS-198 / SRS-297 / SRS-300 / SDD-091: React to macOS Input
/// Monitoring state changes by updating the PTT capability level.
/// When the macOS permissions service detects that Input Monitoring
/// has been granted (via polling), it emits `L1MacOSEventTap` which
/// overrides the bridge-emitted `L0Focused` default.
void _onMacOSPttCapabilityChanged() {
final level = _macOSPermissions.pttCapabilityState.value;
// Only override if the macOS service has a resolved state
// different from the current bridge-emitted level, and only
// on macOS.
if (_isMacOS && level != _pttLevel) {
setState(() {
_pttLevel = level;
if (level == 'L1MacOSEventTap') {
_pttBackendId = 'macos-event-tap';
} else if (level == 'L0Focused') {
_pttBackendId = 'focused';
}
});
}
}
Future<void> _clearPermissionHardMute() async { Future<void> _clearPermissionHardMute() async {
if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return; if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return;
_permissionHardMuteClearInFlight = true; _permissionHardMuteClearInFlight = true;
@@ -633,7 +676,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_reconnectDelay = null; _reconnectDelay = null;
}); });
case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs): case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs):
_recordUiDiagnostic('connection', 'reconnecting attempt=$attempt delay=${delaySecs}s'); _recordUiDiagnostic(
'connection',
'reconnecting attempt=$attempt delay=${delaySecs}s',
);
setState(() { setState(() {
_phase = ConnectionPhase.reconnecting; _phase = ConnectionPhase.reconnecting;
_reconnectAttempt = attempt; _reconnectAttempt = attempt;
@@ -808,9 +854,19 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
); );
return updated; return updated;
}); });
case rust.BridgeEvent_ClientJoined(:final clientId, :final channelId, :final name, :final inputMuted, :final outputMuted, :final isServerQuery, :final talkPower, :final talkPowerGranted): case rust.BridgeEvent_ClientJoined(
:final clientId,
:final channelId,
:final name,
:final inputMuted,
:final outputMuted,
:final isServerQuery,
:final talkPower,
:final talkPowerGranted,
):
if (!isServerQuery) { if (!isServerQuery) {
_applyClientAdd(rust.BridgeClient( _applyClientAdd(
rust.BridgeClient(
id: clientId, id: clientId,
channel: channelId, channel: channelId,
name: name, name: name,
@@ -820,11 +876,19 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
isServerQuery: isServerQuery, isServerQuery: isServerQuery,
talkPower: talkPower, talkPower: talkPower,
talkPowerGranted: talkPowerGranted, talkPowerGranted: talkPowerGranted,
)); ),
);
} }
case rust.BridgeEvent_ClientLeft(:final clientId): case rust.BridgeEvent_ClientLeft(:final clientId):
_applyClientRemove(clientId); _applyClientRemove(clientId);
case rust.BridgeEvent_ClientUpdated(:final clientId, :final inputMuted, :final outputMuted, :final isServerQuery, :final talkPower, :final talkPowerGranted): case rust.BridgeEvent_ClientUpdated(
:final clientId,
:final inputMuted,
:final outputMuted,
:final isServerQuery,
:final talkPower,
:final talkPowerGranted,
):
_applyClientDelta((c) => c.id == clientId, (c) { _applyClientDelta((c) => c.id == clientId, (c) {
final updated = rust.BridgeClient( final updated = rust.BridgeClient(
id: c.id, id: c.id,
@@ -839,18 +903,32 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
); );
return updated; return updated;
}); });
case rust.BridgeEvent_ChannelAdded(:final id, :final parent, :final name, :final order, :final hasPassword, :final neededTalkPower): case rust.BridgeEvent_ChannelAdded(
_applyChannelAdd(rust.BridgeChannel( :final id,
:final parent,
:final name,
:final order,
:final hasPassword,
:final neededTalkPower,
):
_applyChannelAdd(
rust.BridgeChannel(
id: id, id: id,
parent: parent, parent: parent,
name: name, name: name,
order: order, order: order,
hasPassword: hasPassword, hasPassword: hasPassword,
neededTalkPower: neededTalkPower, neededTalkPower: neededTalkPower,
)); ),
);
case rust.BridgeEvent_ChannelRemoved(:final id): case rust.BridgeEvent_ChannelRemoved(:final id):
_applyChannelRemove(id); _applyChannelRemove(id);
case rust.BridgeEvent_ChannelUpdated(:final id, :final name, :final hasPassword, :final neededTalkPower): case rust.BridgeEvent_ChannelUpdated(
:final id,
:final name,
:final hasPassword,
:final neededTalkPower,
):
_applyChannelDelta((ch) => ch.id == id, (ch) { _applyChannelDelta((ch) => ch.id == id, (ch) {
return rust.BridgeChannel( return rust.BridgeChannel(
id: ch.id, id: ch.id,
@@ -888,7 +966,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final now = DateTime.now(); final now = DateTime.now();
if (_inChannel && if (_inChannel &&
(_lastSpeakingRefresh == null || (_lastSpeakingRefresh == null ||
now.difference(_lastSpeakingRefresh!) >= _speakingRefreshInterval)) { now.difference(_lastSpeakingRefresh!) >=
_speakingRefreshInterval)) {
_lastSpeakingRefresh = now; _lastSpeakingRefresh = now;
unawaited(_refreshSnapshot(recordActivity: false, reportErrors: false)); unawaited(_refreshSnapshot(recordActivity: false, reportErrors: false));
} }
@@ -918,6 +997,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey); HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey);
_eventsSub?.cancel(); _eventsSub?.cancel();
_inputLevelSub?.cancel();
_statsTimer?.cancel(); _statsTimer?.cancel();
_snapshotRefreshInFlight = false; _snapshotRefreshInFlight = false;
_snapshotRefreshQueued = false; _snapshotRefreshQueued = false;
@@ -936,11 +1016,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_iosPermissions.recordAudioState.removeListener( _iosPermissions.recordAudioState.removeListener(
_onRecordAudioPermissionChanged, _onRecordAudioPermissionChanged,
); );
// SRS-198 / SRS-297: detach macOS permission listeners.
_macOSPermissions.pttCapabilityState.removeListener(
_onMacOSPttCapabilityChanged,
);
// SDD-106: detach the Kotlin -> Dart MethodChannel handler so a // SDD-106: detach the Kotlin -> Dart MethodChannel handler so a
// late invokeMethod from the platform side cannot land on this // late invokeMethod from the platform side cannot land on this
// disposed state. // disposed state.
_androidPermissions.stop(); _androidPermissions.stop();
_iosPermissions.stop(); _iosPermissions.stop();
_macOSPermissions.stop();
super.dispose(); super.dispose();
} }
@@ -1084,14 +1169,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
Future<void> _toggleOutputMute() async { Future<void> _toggleOutputMute() async {
final next = !_outputMuted; final next = !_outputMuted;
try {
await rust.setOutputMuted(muted: next);
if (!mounted) return;
setState(() { setState(() {
_outputMuted = next; _outputMuted = next;
}); });
try {
await rust.setOutputMuted(muted: next);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() {
_outputMuted = !next;
});
_showUiError('output mute', e); _showUiError('output mute', e);
} }
} }
@@ -1218,6 +1305,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
return; return;
} }
final next = !_hardMute; final next = !_hardMute;
final previousInputMuted = _inputMuted;
final previousHardMute = _hardMute;
final previousPermissionMute = _hardMuteByPermission;
setState(() {
_inputMuted = next;
_hardMute = next;
if (next) {
_hardMuteByPermission = false;
}
});
try { try {
// Hard-mute is two coordinated effects: // Hard-mute is two coordinated effects:
// * setHardMute — local TransmitGate clamp; we stop sending // * setHardMute — local TransmitGate clamp; we stop sending
@@ -1230,14 +1327,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
// through. Drive them together. // through. Drive them together.
await rust.setHardMute(muted: next); await rust.setHardMute(muted: next);
await rust.setInputMuted(muted: next); await rust.setInputMuted(muted: next);
if (!mounted) return;
setState(() {
_inputMuted = next;
_hardMute = next;
_hardMuteByPermission = false;
});
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() {
_inputMuted = previousInputMuted;
_hardMute = previousHardMute;
_hardMuteByPermission = previousPermissionMute;
});
_showUiError('hard mute', e); _showUiError('hard mute', e);
} }
} }
@@ -1695,7 +1791,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}; };
} }
void _applyClientDelta(bool Function(rust.BridgeClient) test, rust.BridgeClient Function(rust.BridgeClient) update) { void _applyClientDelta(
bool Function(rust.BridgeClient) test,
rust.BridgeClient Function(rust.BridgeClient) update,
) {
final snap = _snapshot; final snap = _snapshot;
if (snap == null) return; if (snap == null) return;
final clients = snap.clients.map((c) => test(c) ? update(c) : c).toList(); final clients = snap.clients.map((c) => test(c) ? update(c) : c).toList();
@@ -1781,10 +1880,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}); });
} }
void _applyChannelDelta(bool Function(rust.BridgeChannel) test, rust.BridgeChannel Function(rust.BridgeChannel) update) { void _applyChannelDelta(
bool Function(rust.BridgeChannel) test,
rust.BridgeChannel Function(rust.BridgeChannel) update,
) {
final snap = _snapshot; final snap = _snapshot;
if (snap == null) return; if (snap == null) return;
final channels = snap.channels.map((ch) => test(ch) ? update(ch) : ch).toList(); final channels = snap.channels
.map((ch) => test(ch) ? update(ch) : ch)
.toList();
setState(() { setState(() {
_snapshot = rust.BridgeSnapshot( _snapshot = rust.BridgeSnapshot(
serverName: snap.serverName, serverName: snap.serverName,
@@ -2103,26 +2207,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final theme = Theme.of(context); final theme = Theme.of(context);
final headerActions = [ final headerActions = [
if (_serverReachable && _inChannel) ...[
IconButton(
tooltip: _hardMuteByTalkPower
? 'Insufficient talk power to speak in this channel'
: l10n.voiceHardMuteLabel,
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
isSelected: _hardMute,
selectedIcon: const Icon(Icons.mic_off),
color: _hardMute ? theme.colorScheme.error : null,
onPressed: _hardMuteByTalkPower ? null : _onToggleHardMute,
),
IconButton(
tooltip: l10n.voiceOutputMuteLabel,
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
isSelected: _outputMuted,
selectedIcon: const Icon(Icons.headset_off),
color: _outputMuted ? theme.colorScheme.error : null,
onPressed: _toggleOutputMute,
),
],
IconButton( IconButton(
tooltip: l10n.aboutAction, tooltip: l10n.aboutAction,
icon: const Icon(Icons.info_outline), icon: const Icon(Icons.info_outline),
@@ -2334,6 +2418,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_currentVoiceChannelId, _currentVoiceChannelId,
), ),
audioStats: _audioStats, audioStats: _audioStats,
inputLevel: _inputLevel,
pttLevel: _pttLevel, pttLevel: _pttLevel,
pttBackendId: _pttBackendId, pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass, pttBoundInputClass: _pttBoundInputClass,
@@ -2415,10 +2500,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
isTouchOnly: isTouchOnlyPttHost, isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute, inputMuted: _hardMute,
outputMuted: _outputMuted, outputMuted: _outputMuted,
hardMuteByTalkPower: _hardMuteByTalkPower,
talkPower: ownClientState?.talkPower, talkPower: ownClientState?.talkPower,
neededTalkPower: ownClientState?.neededTalkPower, neededTalkPower: ownClientState?.neededTalkPower,
talkPowerGranted: ownClientState?.talkPowerGranted, talkPowerGranted: ownClientState?.talkPowerGranted,
onTap: () => _onOpenVoiceDetailsSheet(), onTap: () => _onOpenVoiceDetailsSheet(),
onToggleInputMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
), ),
if (_inChannel && if (_inChannel &&
_transmitMode == rust.BridgeTransmitMode.ptt) ...[ _transmitMode == rust.BridgeTransmitMode.ptt) ...[
@@ -0,0 +1,500 @@
/// macOS permission integration for Input Monitoring, Local Network,
/// and Notifications.
///
/// Trace:
/// - SRS-198 (Push-to-talk system permission acquisition).
/// - SRS-297 / SRS-300 (Input Monitoring for global PTT on macOS).
/// - SysRS-166 (Desktop notifications).
/// - SDD-091 (PTT capability badge — live capability level).
///
/// Responsibilities:
/// * Subscribe to the Swift-side `MethodChannel`
/// `app.chanora/macos_permissions` for inbound state-change
/// invocations emitted by the native handler in
/// `MainFlutterWindow.swift` (Swift → Dart).
/// * Provide imperative Dart → Swift entry points for checking and
/// requesting Input Monitoring, triggering the Local Network
/// prompt, and requesting notification authorization.
/// * Expose the latest resolved states as [ValueListenable] so UI
/// surfaces (PTT capability badge, permission banners, connection
/// error messages) can react without polling.
///
/// ## Non-macOS short-circuit
///
/// On Android / iOS / Linux / Windows / web, none of these macOS-
/// specific permissions exist. The MethodChannel is therefore never
/// constructed off-macOS. Each state listenable stays at its default
/// "granted" / "not needed" value so all consumers become no-ops.
///
/// ## No global statics
///
/// Following the pattern established by `AndroidPermissionsService`
/// (SDD-106) and `BackIntentService` (SDD-028), this class is
/// constructor-injected. The host app instantiates one instance at
/// startup and passes it through the widget tree.
library;
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
// ---------------------------------------------------------------------------
// Channel constants
// ---------------------------------------------------------------------------
/// MethodChannel name shared with Swift `MacOSPermissionsHandler`.
@visibleForTesting
const String macOSPermissionsChannelName =
'app.chanora/macos_permissions';
// Outbound (Dart → Swift) method names.
@visibleForTesting
const String methodCheckInputMonitoring = 'checkInputMonitoring';
@visibleForTesting
const String methodRequestInputMonitoring = 'requestInputMonitoring';
@visibleForTesting
const String methodOpenInputMonitoringSettings =
'openInputMonitoringSettings';
@visibleForTesting
const String methodTriggerLocalNetworkPrompt = 'triggerLocalNetworkPrompt';
@visibleForTesting
const String methodCheckLocalNetwork = 'checkLocalNetwork';
@visibleForTesting
const String methodRequestNotifications = 'requestNotifications';
@visibleForTesting
const String methodCheckNotifications = 'checkNotifications';
// Inbound (Swift → Dart) method names.
@visibleForTesting
const String methodInputMonitoringStateChanged =
'inputMonitoringStateChanged';
@visibleForTesting
const String methodLocalNetworkStateChanged = 'localNetworkStateChanged';
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
/// Discrete states for macOS-specific permissions.
enum MacOSPermissionState {
/// Permission granted.
granted,
/// Permission denied by the user.
denied,
/// Permission has not yet been determined (first launch before
/// any prompt, or the system returned an unexpected value).
notDetermined,
/// No resolved state yet (cold launch before the first emission,
/// or non-macOS host before short-circuit). Consumers treat this
/// as "not yet known".
unknown,
}
/// Local Network permission states, extended to cover macOS 14 and
/// earlier where Local Network Privacy does not exist.
enum MacOSLocalNetworkState {
/// Permission granted or the Local Network prompt was satisfied.
granted,
/// Permission explicitly denied by the user (macOS 15+ only).
denied,
/// No prompt shown yet.
notDetermined,
/// Running on macOS 14 or earlier where Local Network Privacy
/// does not apply. Consumers treat this as "granted".
unsupported,
/// No resolved state yet.
unknown,
}
// ---------------------------------------------------------------------------
// State parsing helpers
// ---------------------------------------------------------------------------
MacOSPermissionState _parsePermissionState(String? raw) {
switch (raw) {
case 'Granted':
return MacOSPermissionState.granted;
case 'Denied':
return MacOSPermissionState.denied;
case 'NotDetermined':
return MacOSPermissionState.notDetermined;
default:
return MacOSPermissionState.unknown;
}
}
MacOSLocalNetworkState _parseLocalNetworkState(String? raw) {
switch (raw) {
case 'Granted':
return MacOSLocalNetworkState.granted;
case 'Denied':
return MacOSLocalNetworkState.denied;
case 'NotDetermined':
return MacOSLocalNetworkState.notDetermined;
case 'Unsupported':
return MacOSLocalNetworkState.unsupported;
default:
return MacOSLocalNetworkState.unknown;
}
}
// ---------------------------------------------------------------------------
// PTT capability level mapping
// ---------------------------------------------------------------------------
/// Maps the Input Monitoring state to the PTT capability level string
/// consumed by [PttCapabilityBadge].
///
/// Trace: SDD-091 (capability badge); desktop-ptt-architecture.md
/// (macOS Event Tap strategy).
String _pttCapabilityLevel(MacOSPermissionState inputMonitoring) {
switch (inputMonitoring) {
case MacOSPermissionState.granted:
return 'L1MacOSEventTap';
case MacOSPermissionState.denied:
case MacOSPermissionState.notDetermined:
case MacOSPermissionState.unknown:
return 'L0Focused';
}
}
// ---------------------------------------------------------------------------
// MacOSPermissionsService
// ---------------------------------------------------------------------------
/// Dart-side integration for macOS permission state.
///
/// Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091.
class MacOSPermissionsService {
/// Construct a service bound to [channel]. Injected for testability;
/// production code uses the default channel keyed on
/// [macOSPermissionsChannelName].
MacOSPermissionsService({MethodChannel? channel})
: _channel = channel ??
(_isMacOS
? const MethodChannel(macOSPermissionsChannelName)
: null);
/// Platform-detection seam. Web counts as non-macOS.
static bool get _isMacOS {
if (kIsWeb) return false;
return Platform.isMacOS;
}
final MethodChannel? _channel;
bool _started = false;
// -- Input Monitoring -----------------------------------------------------
final ValueNotifier<MacOSPermissionState> _inputMonitoringState =
ValueNotifier<MacOSPermissionState>(
// Non-macOS: granted so consumers are no-ops.
_isMacOS
? MacOSPermissionState.unknown
: MacOSPermissionState.granted,
);
/// Latest known Input Monitoring permission state.
///
/// On macOS this drives the PTT capability level: `granted` →
/// `L1MacOSEventTap` (global PTT via Event Tap); anything else →
/// `L0Focused` (focused-only PTT).
ValueListenable<MacOSPermissionState> get inputMonitoringState =>
_inputMonitoringState;
// -- Local Network --------------------------------------------------------
final ValueNotifier<MacOSLocalNetworkState> _localNetworkState =
ValueNotifier<MacOSLocalNetworkState>(
_isMacOS
? MacOSLocalNetworkState.unknown
: MacOSLocalNetworkState.unsupported,
);
/// Latest known Local Network permission state.
///
/// On macOS 15+ (Sequoia) this reflects the Local Network Privacy
/// TCC permission. On macOS 14 and earlier, the value is
/// [MacOSLocalNetworkState.unsupported] (no prompt needed).
ValueListenable<MacOSLocalNetworkState> get localNetworkState =>
_localNetworkState;
// -- Notifications --------------------------------------------------------
final ValueNotifier<MacOSPermissionState> _notificationState =
ValueNotifier<MacOSPermissionState>(
_isMacOS
? MacOSPermissionState.unknown
: MacOSPermissionState.granted,
);
/// Latest known notification authorization state.
ValueListenable<MacOSPermissionState> get notificationState =>
_notificationState;
// -- PTT capability (derived) ---------------------------------------------
final ValueNotifier<String> _pttCapabilityState =
ValueNotifier<String>(
_pttCapabilityLevel(
_isMacOS ? MacOSPermissionState.unknown : MacOSPermissionState.granted,
),
);
/// Derived PTT capability level string, ready for consumption by
/// [PttCapabilityBadge]. Updates automatically when Input Monitoring
/// state changes.
///
/// Returns `"L1MacOSEventTap"` when Input Monitoring is granted,
/// `"L0Focused"` otherwise.
ValueListenable<String> get pttCapabilityState => _pttCapabilityState;
// -- Lifecycle ------------------------------------------------------------
/// Start listening for state updates from Swift. Idempotent.
///
/// On non-macOS this is a no-op.
///
/// Note: this only registers the inbound handler. Call
/// [checkInitialStates] afterward to eagerly query the current
/// permission states from the native side.
void start() {
if (_started) return;
_started = true;
final ch = _channel;
if (ch == null) return;
ch.setMethodCallHandler(_handle);
}
/// Eagerly query the current permission states from the native
/// side. Call this after [start] so the PTT capability badge
/// shows the correct level on the first frame.
///
/// On non-macOS this is a no-op.
void checkInitialStates() {
final ch = _channel;
if (ch == null) return;
unawaited(_checkInputMonitoring());
unawaited(_checkLocalNetwork());
unawaited(_checkNotifications());
}
/// Stop listening. Idempotent.
void stop() {
if (!_started) return;
_started = false;
final ch = _channel;
if (ch == null) return;
ch.setMethodCallHandler(null);
}
// -- Inbound handler (Swift → Dart) --------------------------------------
Future<dynamic> _handle(MethodCall call) async {
switch (call.method) {
case methodInputMonitoringStateChanged:
final args = call.arguments;
if (args is Map) {
final state = _parsePermissionState(args['state'] as String?);
_inputMonitoringState.value = state;
_pttCapabilityState.value = _pttCapabilityLevel(state);
}
break;
case methodLocalNetworkStateChanged:
final args = call.arguments;
if (args is Map) {
_localNetworkState.value =
_parseLocalNetworkState(args['state'] as String?);
}
break;
default:
break;
}
return null;
}
// -- Outbound: Input Monitoring -------------------------------------------
/// Check the current Input Monitoring permission state without
/// triggering a system prompt.
Future<MacOSPermissionState> _checkInputMonitoring() async {
final ch = _channel;
if (ch == null) return MacOSPermissionState.granted;
try {
final raw =
await ch.invokeMethod<String>(methodCheckInputMonitoring);
final state = _parsePermissionState(raw);
_inputMonitoringState.value = state;
_pttCapabilityState.value = _pttCapabilityLevel(state);
return state;
} catch (e, st) {
developer.log(
'checkInputMonitoring failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _inputMonitoringState.value;
}
}
/// Request Input Monitoring permission. On macOS this calls
/// `CGRequestListenEventAccess()` which shows a system dialog
/// or opens System Settings (depending on macOS version).
///
/// Returns the new state after the request.
Future<MacOSPermissionState> requestInputMonitoring() async {
final ch = _channel;
if (ch == null) return MacOSPermissionState.granted;
try {
final raw = await ch.invokeMethod<String>(
methodRequestInputMonitoring,
);
final state = _parsePermissionState(raw);
_inputMonitoringState.value = state;
_pttCapabilityState.value = _pttCapabilityLevel(state);
return state;
} catch (e, st) {
developer.log(
'requestInputMonitoring failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _inputMonitoringState.value;
}
}
/// Open System Settings → Privacy & Security → Input Monitoring
/// so the user can manually grant the permission for the
/// permanently-denied case (TCC drag-based permission cannot be
/// programmatically granted).
Future<void> openInputMonitoringSettings() async {
final ch = _channel;
if (ch == null) return;
try {
await ch.invokeMethod<void>(methodOpenInputMonitoringSettings);
} catch (e, st) {
developer.log(
'openInputMonitoringSettings failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
}
}
// -- Outbound: Local Network ----------------------------------------------
Future<MacOSLocalNetworkState> _checkLocalNetwork() async {
final ch = _channel;
if (ch == null) return MacOSLocalNetworkState.unsupported;
try {
final raw = await ch.invokeMethod<String>(methodCheckLocalNetwork);
final state = _parseLocalNetworkState(raw);
_localNetworkState.value = state;
return state;
} catch (e, st) {
developer.log(
'checkLocalNetwork failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _localNetworkState.value;
}
}
/// Trigger the Local Network permission prompt by starting a
/// brief `NWBrowser` scan for `_ts3._tcp`. On macOS 15+ this
/// shows the system Local Network Privacy dialog. On earlier
/// versions, this is a no-op (returns `unsupported`).
Future<MacOSLocalNetworkState> triggerLocalNetworkPrompt() async {
final ch = _channel;
if (ch == null) return MacOSLocalNetworkState.unsupported;
try {
final raw = await ch.invokeMethod<String>(
methodTriggerLocalNetworkPrompt,
);
final state = _parseLocalNetworkState(raw);
_localNetworkState.value = state;
return state;
} catch (e, st) {
developer.log(
'triggerLocalNetworkPrompt failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _localNetworkState.value;
}
}
// -- Outbound: Notifications ----------------------------------------------
Future<MacOSPermissionState> _checkNotifications() async {
final ch = _channel;
if (ch == null) return MacOSPermissionState.granted;
try {
final raw =
await ch.invokeMethod<String>(methodCheckNotifications);
final state = _parsePermissionState(raw);
_notificationState.value = state;
return state;
} catch (e, st) {
developer.log(
'checkNotifications failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _notificationState.value;
}
}
/// Request notification authorization via `UNUserNotificationCenter`.
/// Returns the new state after the system dialog resolves.
Future<MacOSPermissionState> requestNotifications() async {
final ch = _channel;
if (ch == null) return MacOSPermissionState.granted;
try {
final raw = await ch.invokeMethod<String>(
methodRequestNotifications,
);
final state = _parsePermissionState(raw);
_notificationState.value = state;
return state;
} catch (e, st) {
developer.log(
'requestNotifications failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _notificationState.value;
}
}
// -- Cleanup --------------------------------------------------------------
/// Release state notifiers. Test helper; production keeps the
/// service alive for the lifetime of the app.
@visibleForTesting
void dispose() {
stop();
_inputMonitoringState.dispose();
_localNetworkState.dispose();
_notificationState.dispose();
_pttCapabilityState.dispose();
}
}
+16 -2
View File
@@ -261,6 +261,12 @@ Stream<BridgeEvent> eventsStream() =>
Future<BridgeAudioStats> audioStats() => Future<BridgeAudioStats> audioStats() =>
RustLib.instance.api.crateApiAudioStats(); RustLib.instance.api.crateApiAudioStats();
/// Subscribe to real-time microphone input level at ~30 Hz.
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
/// when the Dart subscriber cancels or the session is dropped.
Stream<double> inputLevelStream() =>
RustLib.instance.api.crateApiInputLevelStream();
/// Apply the P1 audio-processing config. /// Apply the P1 audio-processing config.
Future<void> setAudioProcessingConfig({ Future<void> setAudioProcessingConfig({
required BridgeAudioProcessingConfig config, required BridgeAudioProcessingConfig config,
@@ -681,15 +687,22 @@ class BridgeAudioStats {
/// Current push-to-talk state. /// Current push-to-talk state.
final bool pttActive; final bool pttActive;
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
final double inputLevel;
const BridgeAudioStats({ const BridgeAudioStats({
required this.framesSent, required this.framesSent,
required this.framesReceived, required this.framesReceived,
required this.pttActive, required this.pttActive,
required this.inputLevel,
}); });
@override @override
int get hashCode => int get hashCode =>
framesSent.hashCode ^ framesReceived.hashCode ^ pttActive.hashCode; framesSent.hashCode ^
framesReceived.hashCode ^
pttActive.hashCode ^
inputLevel.hashCode;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -698,7 +711,8 @@ class BridgeAudioStats {
runtimeType == other.runtimeType && runtimeType == other.runtimeType &&
framesSent == other.framesSent && framesSent == other.framesSent &&
framesReceived == other.framesReceived && framesReceived == other.framesReceived &&
pttActive == other.pttActive; pttActive == other.pttActive &&
inputLevel == other.inputLevel;
} }
/// Bookmark DTO mirroring [`chanora_core::Bookmark`]. /// Bookmark DTO mirroring [`chanora_core::Bookmark`].
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0'; String get codegenVersion => '2.12.0';
@override @override
int get rustContentHash => 281698435; int get rustContentHash => -20394775;
static const kDefaultExternalLibraryLoaderConfig = static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig( ExternalLibraryLoaderConfig(
@@ -123,6 +123,8 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiInitStorage({required String dir}); Future<void> crateApiInitStorage({required String dir});
Stream<double> crateApiInputLevelStream();
Future<bool> crateApiIsConnected(); Future<bool> crateApiIsConnected();
Future<BridgeAudioDeviceList> crateApiListAudioDevices(); Future<BridgeAudioDeviceList> crateApiListAudioDevices();
@@ -762,6 +764,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiInitStorageConstMeta => TaskConstMeta get kCrateApiInitStorageConstMeta =>
const TaskConstMeta(debugName: "init_storage", argNames: ["dir"]); const TaskConstMeta(debugName: "init_storage", argNames: ["dir"]);
@override
Stream<double> crateApiInputLevelStream() {
final sink = RustStreamSink<double>();
unawaited(
handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_StreamSink_f_32_Sse(sink, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 21,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiInputLevelStreamConstMeta,
argValues: [sink],
apiImpl: this,
),
),
);
return sink.stream;
}
TaskConstMeta get kCrateApiInputLevelStreamConstMeta =>
const TaskConstMeta(debugName: "input_level_stream", argNames: ["sink"]);
@override @override
Future<bool> crateApiIsConnected() { Future<bool> crateApiIsConnected() {
return handler.executeNormal( return handler.executeNormal(
@@ -771,7 +805,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 21, funcId: 22,
port: port_, port: port_,
); );
}, },
@@ -798,7 +832,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 22, funcId: 23,
port: port_, port: port_,
); );
}, },
@@ -825,7 +859,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 23, funcId: 24,
port: port_, port: port_,
); );
}, },
@@ -849,7 +883,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask( SyncTask(
callFfi: () { callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24)!; return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!;
}, },
codec: SseCodec( codec: SseCodec(
decodeSuccessData: sse_decode_String, decodeSuccessData: sse_decode_String,
@@ -879,7 +913,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 25, funcId: 26,
port: port_, port: port_,
); );
}, },
@@ -909,7 +943,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 26, funcId: 27,
port: port_, port: port_,
); );
}, },
@@ -936,7 +970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 27, funcId: 28,
port: port_, port: port_,
); );
}, },
@@ -961,7 +995,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () { callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(state, serializer); sse_encode_String(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!; return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!;
}, },
codec: SseCodec( codec: SseCodec(
decodeSuccessData: sse_decode_unit, decodeSuccessData: sse_decode_unit,
@@ -994,7 +1028,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 29, funcId: 30,
port: port_, port: port_,
); );
}, },
@@ -1021,7 +1055,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () { callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_audio_route(route, serializer); sse_encode_bridge_audio_route(route, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!; return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!;
}, },
codec: SseCodec( codec: SseCodec(
decodeSuccessData: sse_decode_unit, decodeSuccessData: sse_decode_unit,
@@ -1055,7 +1089,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 31, funcId: 32,
port: port_, port: port_,
); );
}, },
@@ -1090,7 +1124,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 32, funcId: 33,
port: port_, port: port_,
); );
}, },
@@ -1120,7 +1154,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 33, funcId: 34,
port: port_, port: port_,
); );
}, },
@@ -1148,7 +1182,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 34, funcId: 35,
port: port_, port: port_,
); );
}, },
@@ -1176,7 +1210,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 35, funcId: 36,
port: port_, port: port_,
); );
}, },
@@ -1206,7 +1240,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 36, funcId: 37,
port: port_, port: port_,
); );
}, },
@@ -1234,7 +1268,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () { callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_network_state(state, serializer); sse_encode_bridge_network_state(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!; return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!;
}, },
codec: SseCodec( codec: SseCodec(
decodeSuccessData: sse_decode_unit, decodeSuccessData: sse_decode_unit,
@@ -1260,7 +1294,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 38, funcId: 39,
port: port_, port: port_,
); );
}, },
@@ -1288,7 +1322,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 39, funcId: 40,
port: port_, port: port_,
); );
}, },
@@ -1316,7 +1350,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 40, funcId: 41,
port: port_, port: port_,
); );
}, },
@@ -1344,7 +1378,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 41, funcId: 42,
port: port_, port: port_,
); );
}, },
@@ -1376,7 +1410,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 42, funcId: 43,
port: port_, port: port_,
); );
}, },
@@ -1406,7 +1440,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 43, funcId: 44,
port: port_, port: port_,
); );
}, },
@@ -1434,7 +1468,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 44, funcId: 45,
port: port_, port: port_,
); );
}, },
@@ -1462,7 +1496,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 45, funcId: 46,
port: port_, port: port_,
); );
}, },
@@ -1489,7 +1523,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 46, funcId: 47,
port: port_, port: port_,
); );
}, },
@@ -1517,7 +1551,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 47, funcId: 48,
port: port_, port: port_,
); );
}, },
@@ -1549,7 +1583,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 48, funcId: 49,
port: port_, port: port_,
); );
}, },
@@ -1578,7 +1612,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 49, funcId: 50,
port: port_, port: port_,
); );
}, },
@@ -1610,6 +1644,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
throw UnimplementedError(); throw UnimplementedError();
} }
@protected
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
throw UnimplementedError();
}
@protected @protected
String dco_decode_String(dynamic raw) { String dco_decode_String(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
@@ -1781,12 +1821,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) { BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>; final arr = raw as List<dynamic>;
if (arr.length != 3) if (arr.length != 4)
throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); throw Exception('unexpected arr length: expect 4 but see ${arr.length}');
return BridgeAudioStats( return BridgeAudioStats(
framesSent: dco_decode_u_32(arr[0]), framesSent: dco_decode_u_32(arr[0]),
framesReceived: dco_decode_u_32(arr[1]), framesReceived: dco_decode_u_32(arr[1]),
pttActive: dco_decode_bool(arr[2]), pttActive: dco_decode_bool(arr[2]),
inputLevel: dco_decode_f_32(arr[3]),
); );
} }
@@ -2271,6 +2312,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
throw UnimplementedError('Unreachable ()'); throw UnimplementedError('Unreachable ()');
} }
@protected
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
throw UnimplementedError('Unreachable ()');
}
@protected @protected
String sse_decode_String(SseDeserializer deserializer) { String sse_decode_String(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
@@ -2488,10 +2537,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_framesSent = sse_decode_u_32(deserializer); var var_framesSent = sse_decode_u_32(deserializer);
var var_framesReceived = sse_decode_u_32(deserializer); var var_framesReceived = sse_decode_u_32(deserializer);
var var_pttActive = sse_decode_bool(deserializer); var var_pttActive = sse_decode_bool(deserializer);
var var_inputLevel = sse_decode_f_32(deserializer);
return BridgeAudioStats( return BridgeAudioStats(
framesSent: var_framesSent, framesSent: var_framesSent,
framesReceived: var_framesReceived, framesReceived: var_framesReceived,
pttActive: var_pttActive, pttActive: var_pttActive,
inputLevel: var_inputLevel,
); );
} }
@@ -3199,6 +3250,23 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
); );
} }
@protected
void sse_encode_StreamSink_f_32_Sse(
RustStreamSink<double> self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_String(
self.setupAndSerialize(
codec: SseCodec(
decodeSuccessData: sse_decode_f_32,
decodeErrorData: sse_decode_AnyhowException,
),
),
serializer,
);
}
@protected @protected
void sse_encode_String(String self, SseSerializer serializer) { void sse_encode_String(String self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
@@ -3380,6 +3448,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_u_32(self.framesSent, serializer); sse_encode_u_32(self.framesSent, serializer);
sse_encode_u_32(self.framesReceived, serializer); sse_encode_u_32(self.framesReceived, serializer);
sse_encode_bool(self.pttActive, serializer); sse_encode_bool(self.pttActive, serializer);
sse_encode_f_32(self.inputLevel, serializer);
} }
@protected @protected
@@ -27,6 +27,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw, dynamic raw,
); );
@protected
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw);
@protected @protected
String dco_decode_String(dynamic raw); String dco_decode_String(dynamic raw);
@@ -210,6 +213,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
SseDeserializer deserializer,
);
@protected @protected
String sse_decode_String(SseDeserializer deserializer); String sse_decode_String(SseDeserializer deserializer);
@@ -439,6 +447,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_StreamSink_f_32_Sse(
RustStreamSink<double> self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_String(String self, SseSerializer serializer); void sse_encode_String(String self, SseSerializer serializer);
@@ -29,6 +29,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw, dynamic raw,
); );
@protected
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw);
@protected @protected
String dco_decode_String(dynamic raw); String dco_decode_String(dynamic raw);
@@ -212,6 +215,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
SseDeserializer deserializer,
);
@protected @protected
String sse_decode_String(SseDeserializer deserializer); String sse_decode_String(SseDeserializer deserializer);
@@ -441,6 +449,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_StreamSink_f_32_Sse(
RustStreamSink<double> self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_String(String self, SseSerializer serializer); void sse_encode_String(String self, SseSerializer serializer);
@@ -33,6 +33,7 @@ class VoiceBar extends StatelessWidget {
required this.onConfigure, required this.onConfigure,
required this.onPttHeldChanged, required this.onPttHeldChanged,
this.talkPowerBlocked = false, this.talkPowerBlocked = false,
this.inputLevel,
}); });
final bool inChannel; final bool inChannel;
@@ -53,6 +54,10 @@ class VoiceBar extends StatelessWidget {
/// level meter. Pass `null` to render an idle meter. /// level meter. Pass `null` to render an idle meter.
final rust.BridgeAudioStats? audioStats; final rust.BridgeAudioStats? audioStats;
/// Real-time input level from the 30 Hz stream (dBFS).
/// When non-null, takes precedence over `audioStats.inputLevel`.
final double? inputLevel;
/// PTT capability badge inputs — passed through to /// PTT capability badge inputs — passed through to
/// [`PttCapabilityBadge`]. /// [`PttCapabilityBadge`].
final String pttLevel; final String pttLevel;
@@ -196,7 +201,7 @@ class VoiceBar extends StatelessWidget {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
// Row 4: level meter // Row 4: level meter
VoiceLevelMeter(active: levelActive), VoiceLevelMeter(active: levelActive, level: inputLevel ?? stats?.inputLevel),
const SizedBox(height: 4), const SizedBox(height: 4),
if (stats != null) if (stats != null)
Text( Text(
@@ -7,7 +7,7 @@
// release-tail are surfaced inline (radio buttons + slider) inside // release-tail are surfaced inline (radio buttons + slider) inside
// the modal. // the modal.
import 'dart:async' show Timer, unawaited; import 'dart:async' show StreamSubscription, Timer, unawaited;
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
@@ -53,8 +53,11 @@ class VoiceStatusChip extends StatelessWidget {
required this.audioStats, required this.audioStats,
required this.isTouchOnly, required this.isTouchOnly,
required this.onTap, required this.onTap,
required this.onToggleInputMute,
required this.onToggleOutputMute,
this.inputMuted = false, this.inputMuted = false,
this.outputMuted = false, this.outputMuted = false,
this.hardMuteByTalkPower = false,
this.talkPower, this.talkPower,
this.neededTalkPower, this.neededTalkPower,
this.talkPowerGranted, this.talkPowerGranted,
@@ -81,6 +84,9 @@ class VoiceStatusChip extends StatelessWidget {
/// True when local speaker is muted. /// True when local speaker is muted.
final bool outputMuted; final bool outputMuted;
/// True when the server talk-power gate forces local hard mute.
final bool hardMuteByTalkPower;
/// Own client's talk power. /// Own client's talk power.
final int? talkPower; final int? talkPower;
@@ -93,6 +99,12 @@ class VoiceStatusChip extends StatelessWidget {
/// Open the voice details modal. /// Open the voice details modal.
final VoidCallback onTap; final VoidCallback onTap;
/// Toggle local input hard mute.
final VoidCallback onToggleInputMute;
/// Toggle local output mute/deafen.
final VoidCallback onToggleOutputMute;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -113,18 +125,9 @@ class VoiceStatusChip extends StatelessWidget {
); );
return Semantics( return Semantics(
button: true,
label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}', label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}',
hint: l10n.voiceSettingsTitle,
child: Material( child: Material(
type: MaterialType.transparency, type: MaterialType.transparency,
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onTap();
},
borderRadius: BorderRadius.circular(12),
child: ExcludeSemantics(
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -156,6 +159,14 @@ class VoiceStatusChip extends StatelessWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onTap();
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -179,18 +190,63 @@ class VoiceStatusChip extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(width: 8), ),
Icon( ),
const SizedBox(width: 4),
IconButton(
tooltip: hardMuteByTalkPower
? l10n.voiceTalkPowerBlocked
: l10n.voiceHardMuteLabel,
icon: Icon(inputMuted ? Icons.mic_off : Icons.mic),
color: inputMuted ? theme.colorScheme.error : null,
onPressed: hardMuteByTalkPower ? null : onToggleInputMute,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 40,
height: 40,
),
padding: EdgeInsets.zero,
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
IconButton(
tooltip: l10n.voiceOutputMuteLabel,
icon: Icon(outputMuted ? Icons.headset_off : Icons.headset),
color: outputMuted ? theme.colorScheme.error : null,
onPressed: onToggleOutputMute,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 40,
height: 40,
),
padding: EdgeInsets.zero,
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
IconButton(
tooltip: l10n.voiceSettingsTitle,
icon: Icon(
Icons.expand_less, Icons.expand_less,
size: 18, size: 18,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
onPressed: onTap,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 40,
height: 40,
),
padding: EdgeInsets.zero,
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
], ],
), ),
), ),
), ),
),
),
); );
} }
} }
@@ -455,6 +511,8 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
int _rateTickCount = 0; int _rateTickCount = 0;
late final AudioProcessingConfigState _audioProcessing; late final AudioProcessingConfigState _audioProcessing;
double? _streamLevel;
StreamSubscription<double>? _levelSub;
@override @override
void initState() { void initState() {
@@ -463,8 +521,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
widget.initialAudioConfig, widget.initialAudioConfig,
); );
// Poll audio stats at 250 ms so TX/RX counters and the level meter _levelSub = rust.inputLevelStream().listen((level) {
// update in real time while the sheet is open, independent of the parent. if (mounted) setState(() => _streamLevel = level);
});
// Poll audio stats at 250 ms so TX/RX counters update in real time
// while the sheet is open.
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async { _statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
try { try {
final s = await rust.audioStats(); final s = await rust.audioStats();
@@ -472,7 +534,6 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
setState(() { setState(() {
_stats = s; _stats = s;
_rateTickCount++; _rateTickCount++;
// Compute rates every ~1 s (4 × 250 ms).
if (_rateTickCount >= 4) { if (_rateTickCount >= 4) {
_txRate = s.framesSent - _prevSent; _txRate = s.framesSent - _prevSent;
_rxRate = s.framesReceived - _prevReceived; _rxRate = s.framesReceived - _prevReceived;
@@ -487,6 +548,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
@override @override
void dispose() { void dispose() {
_levelSub?.cancel();
_statsTimer?.cancel(); _statsTimer?.cancel();
super.dispose(); super.dispose();
} }
@@ -625,7 +687,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
const SizedBox(height: 12), const SizedBox(height: 12),
// 4) Level meter + live TX/RX stats. // 4) Level meter + live TX/RX stats.
VoiceLevelMeter(active: levelActive), VoiceLevelMeter(active: levelActive, level: _streamLevel ?? stats?.inputLevel),
const SizedBox(height: 6), const SizedBox(height: 6),
_StatsRow( _StatsRow(
txRate: _txRate, txRate: _txRate,
@@ -1,28 +1,78 @@
import 'dart:math' show max;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Shared compact level meter used by voice surfaces. /// Shared compact level meter used by voice surfaces.
class VoiceLevelMeter extends StatelessWidget { ///
const VoiceLevelMeter({super.key, required this.active}); /// When [level] is null (no stats available yet), falls back to [active]
/// for a binary indicator. When [level] is provided it is interpreted as
/// dBFS and mapped to a 01 fill fraction via [dbfsToFraction] (floors
/// at -60 dBFS).
class VoiceLevelMeter extends StatefulWidget {
const VoiceLevelMeter({super.key, this.active = false, this.level});
/// Binary fallback when no dBFS value is available.
final bool active; final bool active;
/// Real input level in dBFS (-120 = silence, 0 = clipping).
/// Null means stats are not yet available; [active] is used instead.
final double? level;
/// Map dBFS [-60, 0] → [0.0, 1.0].
static double dbfsToFraction(double dbfs) {
const floor = -60.0;
if (dbfs <= floor) return 0.0;
if (dbfs >= 0.0) return 1.0;
return (dbfs - floor) / -floor;
}
@override
State<VoiceLevelMeter> createState() => _VoiceLevelMeterState();
}
class _VoiceLevelMeterState extends State<VoiceLevelMeter> {
double _previousFill = 0.0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final double fill;
final Color color;
if (widget.level != null) {
fill = VoiceLevelMeter.dbfsToFraction(widget.level!);
color = fill > 0.0
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant;
} else {
fill = widget.active ? 0.75 : 0.05;
color = widget.active
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant;
}
final begin = _previousFill;
_previousFill = fill;
return Container( return Container(
height: 8, height: 8,
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: FractionallySizedBox( child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: begin, end: fill),
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
builder: (context, animatedFill, child) {
return FractionallySizedBox(
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
widthFactor: active ? 0.75 : 0.05, widthFactor: max(animatedFill, 0.02),
child: child,
);
},
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: active color: color,
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
@@ -1 +0,0 @@
Versions/Current/Resources
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key><string>chanora_bridge</string>
<key>CFBundleIdentifier</key><string>app.chanora.bridge</string>
<key>CFBundleName</key><string>chanora_bridge</string>
<key>CFBundlePackageType</key><string>FMWK</string>
<key>CFBundleShortVersionString</key><string>1.0.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleSupportedPlatforms</key><array><string>MacOSX</string></array>
<key>MinimumOSVersion</key><string>10.15</string>
</dict>
</plist>
@@ -1 +0,0 @@
Versions/Current/chanora_bridge
-37
View File
@@ -1,57 +1,20 @@
PODS: PODS:
- audio_session (0.0.1):
- FlutterMacOS
- chanora_bridge (1.0.0) - chanora_bridge (1.0.0)
- connectivity_plus (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0) - FlutterMacOS (1.0.0)
- package_info_plus (0.0.1):
- FlutterMacOS
- share_plus (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
DEPENDENCIES: DEPENDENCIES:
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
- chanora_bridge (from `/Users/edison/dev/chanora/apps/chanora_flutter/macos`) - chanora_bridge (from `/Users/edison/dev/chanora/apps/chanora_flutter/macos`)
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
EXTERNAL SOURCES: EXTERNAL SOURCES:
audio_session:
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
chanora_bridge: chanora_bridge:
:path: "/Users/edison/dev/chanora/apps/chanora_flutter/macos" :path: "/Users/edison/dev/chanora/apps/chanora_flutter/macos"
connectivity_plus:
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
FlutterMacOS: FlutterMacOS:
:path: Flutter/ephemeral :path: Flutter/ephemeral
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
share_plus:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS: SPEC CHECKSUMS:
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
chanora_bridge: 4105993843b5421ee4ce72220a74c63f6fd99103 chanora_bridge: 4105993843b5421ee4ce72220a74c63f6fd99103
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb
@@ -32,6 +32,7 @@
45F255D1DE0134185DB5423D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 06E1AA7E1FB968C1D78DA8DE /* PrivacyInfo.xcprivacy */; }; 45F255D1DE0134185DB5423D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 06E1AA7E1FB968C1D78DA8DE /* PrivacyInfo.xcprivacy */; };
9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */; }; 9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */; };
C2DC22E19FDCE26B9D79442E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */; }; C2DC22E19FDCE26B9D79442E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -93,6 +94,7 @@
ED6F5EE0C5FD4DA22D79188C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; ED6F5EE0C5FD4DA22D79188C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -108,6 +110,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
8C6000042DD0000000000001 /* SileroCoreML in Frameworks */, 8C6000042DD0000000000001 /* SileroCoreML in Frameworks */,
9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */, 9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */,
); );
@@ -170,6 +173,7 @@
33CEB47122A05771004F2AC0 /* Flutter */ = { 33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
@@ -256,6 +260,7 @@
); );
name = Runner; name = Runner;
packageProductDependencies = ( packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
8C6000032DD0000000000001 /* SileroCoreML */, 8C6000032DD0000000000001 /* SileroCoreML */,
); );
productName = Runner; productName = Runner;
@@ -302,6 +307,7 @@
); );
mainGroup = 33CC10E42044A3C60003C045; mainGroup = 33CC10E42044A3C60003C045;
packageReferences = ( packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */, 8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
); );
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
@@ -865,6 +871,10 @@
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 */
@@ -873,6 +883,10 @@
package = 8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */; package = 8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
productName = SileroCoreML; productName = SileroCoreML;
}; };
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = 33CC10E52044A3C60003C045 /* Project object */; rootObject = 33CC10E52044A3C60003C045 /* Project object */;
@@ -5,6 +5,24 @@
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "&quot;$FLUTTER_ROOT&quot;/packages/flutter_tools/bin/macos_assemble.sh prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "chanora_flutter.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -40,5 +40,9 @@
<string>Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking.</string> <string>Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking.</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string> <string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
<key>NSBonjourServices</key>
<array>
<string>_ts3._tcp</string>
</array>
</dict> </dict>
</plist> </plist>
@@ -1,5 +1,292 @@
import Cocoa import Cocoa
import FlutterMacOS import FlutterMacOS
import CoreGraphics
import Network
import UserNotifications
// ---------------------------------------------------------------------------
// MacOSPermissionsHandler
//
// Native-side MethodChannel handler for macOS-specific permissions
// that the Flutter `permission_handler` plugin does not cover:
//
// Input Monitoring (CGPreflightListenEventAccess /
// CGRequestListenEventAccess) for global PTT.
// Local Network (NWBrowser trigger for _ts3._tcp) so macOS 15+
// shows the Local Network Privacy prompt.
// Notifications (UNUserNotificationCenter authorization).
//
// Channel name: `app.chanora/macos_permissions`
//
// Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091.
// ---------------------------------------------------------------------------
/// Polling interval for Input Monitoring state changes.
/// TCC does not emit a callback when the user toggles Input Monitoring
/// in System Settings, so we poll at a reasonable cadence.
private let kInputMonitoringPollInterval: TimeInterval = 2.0
class MacOSPermissionsHandler: NSObject, FlutterPlugin {
private var channel: FlutterMethodChannel?
private var inputMonitoringTimer: Timer?
private var lastInputMonitoringState: String = "Unknown"
// -- FlutterPlugin -------------------------------------------------------
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "app.chanora/macos_permissions",
binaryMessenger: registrar.messenger
)
let instance = MacOSPermissionsHandler()
instance.channel = channel
registrar.addMethodCallDelegate(instance, channel: channel)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
// -- Input Monitoring ---------------------------------------------------
case "checkInputMonitoring":
result(inputMonitoringStateString())
startInputMonitoringPolling()
case "requestInputMonitoring":
requestInputMonitoring(result: result)
case "openInputMonitoringSettings":
openInputMonitoringSettings(result: result)
// -- Local Network ------------------------------------------------------
case "checkLocalNetwork":
checkLocalNetwork(result: result)
case "triggerLocalNetworkPrompt":
triggerLocalNetworkPrompt(result: result)
// -- Notifications ------------------------------------------------------
case "checkNotifications":
checkNotifications(result: result)
case "requestNotifications":
requestNotifications(result: result)
default:
result(FlutterMethodNotImplemented)
}
}
// =========================================================================
// Input Monitoring
// =========================================================================
/// Returns the current Input Monitoring state as a string
/// consumable by the Dart side: "Granted", "Denied", "NotDetermined".
private func inputMonitoringStateString() -> String {
// CGPreflightListenEventAccess returns true when access is already
// granted. On macOS 10.15+ it returns false when denied or not yet
// determined we cannot distinguish those two without attempting
// CGRequestListenEventAccess, so we conservatively report
// "NotDetermined" when preflight returns false. The Dart side
// treats both "Denied" and "NotDetermined" as L0Focused.
if CGPreflightListenEventAccess() {
return "Granted"
}
return "NotDetermined"
}
/// Request Input Monitoring permission via
/// `CGRequestListenEventAccess()`. On macOS 13+ this opens
/// System Settings Privacy & Security Input Monitoring.
private func requestInputMonitoring(result: @escaping FlutterResult) {
// CGRequestListenEventAccess shows the system prompt.
// It returns true if access was already granted or becomes
// granted synchronously (rare). Most of the time it returns
// false and the user must toggle the switch manually.
let granted = CGRequestListenEventAccess()
let state = granted ? "Granted" : "NotDetermined"
lastInputMonitoringState = state
result(state)
// Start polling so we detect when the user grants in Settings.
startInputMonitoringPolling()
}
/// Open System Settings Privacy & Security Input Monitoring
/// so the user can manually enable the app.
private func openInputMonitoringSettings(result: @escaping FlutterResult) {
if let url = URL(
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"
) {
NSWorkspace.shared.open(url)
}
result(nil)
}
/// Start a periodic timer that checks Input Monitoring state and
/// notifies the Dart side when it changes.
private func startInputMonitoringPolling() {
// Don't start a second timer if one is already running.
guard inputMonitoringTimer == nil else { return }
lastInputMonitoringState = inputMonitoringStateString()
inputMonitoringTimer = Timer.scheduledTimer(
withTimeInterval: kInputMonitoringPollInterval,
repeats: true
) { [weak self] _ in
self?.pollInputMonitoring()
}
}
private func pollInputMonitoring() {
let current = inputMonitoringStateString()
guard current != lastInputMonitoringState else { return }
lastInputMonitoringState = current
// Notify the Dart side via the inbound method.
channel?.invokeMethod("inputMonitoringStateChanged", arguments: [
"state": current,
])
}
// =========================================================================
// Local Network
// =========================================================================
/// Check whether Local Network access is available.
/// On macOS 14 and earlier, Local Network Privacy does not exist,
/// so we report "Unsupported". On macOS 15+, we attempt a brief
/// NWBrowser scan and report based on the result.
private func checkLocalNetwork(result: @escaping FlutterResult) {
if #available(macOS 15.0, *) {
// We cannot synchronously determine the Local Network state
// without actually using the network. Report "NotDetermined"
// and let triggerLocalNetworkPrompt resolve the actual state.
result("NotDetermined")
} else {
result("Unsupported")
}
}
/// Trigger the Local Network Privacy prompt by starting a brief
/// NWBrowser for `_ts3._tcp`. On macOS 15+, this causes the system
/// to show the Local Network permission dialog if not already
/// determined.
///
/// The browser is started and stopped after a short scan window.
/// State changes are reported back to Dart via
/// `localNetworkStateChanged`.
@available(macOS 15.0, *)
private func triggerLocalNetworkPromptImpl(result: @escaping FlutterResult) {
let bonjourType = "_ts3._tcp"
let browserDescriptor = NWBrowser.Descriptor.bonjourWithTXTRecord(
type: bonjourType, domain: nil)
let browser = NWBrowser(for: browserDescriptor, using: NWParameters.tcp)
var resolved = false
browser.stateUpdateHandler = { [weak self] (browserState: NWBrowser.State) in
switch browserState {
case .ready:
// Browser started successfully local network is accessible.
if !resolved {
resolved = true
result("Granted")
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
"state": "Granted",
])
}
browser.cancel()
case .failed(let error):
if !resolved {
resolved = true
let code = error.errorCode
// POSIX permission-denied or network-down signals that
// the user denied the Local Network prompt.
if code == ENETDOWN || code == EACCES || code == EPERM {
result("Denied")
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
"state": "Denied",
])
} else {
// Network unreachable or other transient error
// don't assume denied.
result("NotDetermined")
}
}
browser.cancel()
case .waiting:
// The browser is waiting for network this is normal and
// may mean the permission dialog is showing. Don't resolve
// yet; wait for .ready or .failed or the timeout.
break
case .setup, .cancelled:
break
@unknown default:
break
}
}
browser.start(queue: DispatchQueue.main)
// Timeout: if the browser doesn't resolve within 10 seconds,
// report NotDetermined so the Dart side doesn't hang forever.
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) {
if !resolved {
resolved = true
result("NotDetermined")
browser.cancel()
}
}
}
private func triggerLocalNetworkPrompt(result: @escaping FlutterResult) {
if #available(macOS 15.0, *) {
triggerLocalNetworkPromptImpl(result: result)
} else {
result("Unsupported")
}
}
// =========================================================================
// Notifications
// =========================================================================
private func checkNotifications(result: @escaping FlutterResult) {
UNUserNotificationCenter.current().getNotificationSettings { settings in
switch settings.authorizationStatus {
case .authorized, .provisional:
result("Granted")
case .denied:
result("Denied")
case .notDetermined:
result("NotDetermined")
@unknown default:
result("NotDetermined")
}
}
}
private func requestNotifications(result: @escaping FlutterResult) {
UNUserNotificationCenter.current().requestAuthorization(options: [
.alert, .sound, .badge,
]) { granted, _ in
let state = granted ? "Granted" : "Denied"
result(state)
}
}
// =========================================================================
// Cleanup
// =========================================================================
deinit {
inputMonitoringTimer?.invalidate()
}
}
// ---------------------------------------------------------------------------
// MainFlutterWindow
// ---------------------------------------------------------------------------
class MainFlutterWindow: NSWindow { class MainFlutterWindow: NSWindow {
override func awakeFromNib() { override func awakeFromNib() {
@@ -15,6 +302,9 @@ class MainFlutterWindow: NSWindow {
RegisterGeneratedPlugins(registry: flutterViewController) RegisterGeneratedPlugins(registry: flutterViewController)
// Register the macOS permissions MethodChannel handler.
MacOSPermissionsHandler.register(with: flutterViewController.registrar(forPlugin: "MacOSPermissionsHandler"))
super.awakeFromNib() super.awakeFromNib()
} }
} }
+4 -4
View File
@@ -457,10 +457,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -790,10 +790,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.11"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -0,0 +1,505 @@
// SWE.4 unit tests for MacOSPermissionsService — the Dart-side
// integration layer for the Swift `MacOSPermissionsHandler`
// (SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091).
//
// Requirement trace:
// Verification-plan row: SWE4-UV-XXX.
// SRS-198 (Push-to-talk system permission acquisition).
// SRS-297 / SRS-300 (Input Monitoring for global PTT on macOS).
// SysRS-166 (Desktop notifications).
// SDD-091 (PTT capability badge — live capability level).
//
// Strategy: Following the pattern in
// `android_permissions_service_test.dart`, use
// `TestDefaultBinaryMessengerBinding` to (a) capture outbound
// method invocations and (b) inject inbound state-change calls as
// if Swift had emitted them.
//
// Platform note: these tests run on macOS. The static `_isMacOS`
// check inside the service evaluates to true at field-initialization
// time, so the ValueNotifiers seed to `unknown` (not `granted`).
// This is intentional — on macOS the service must query the native
// side before it knows the real state. Tests that inject a channel
// observe `unknown` as the initial value and drive transitions from
// there. The channel-null short-circuit test documents that when
// channel is null, outbound calls are suppressed and each method
// returns its safe default.
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/macos_permissions_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
/// Whether the test host is actually macOS — the field initializers
/// inside `MacOSPermissionsService` use `Platform.isMacOS` (not
/// injectable), so the initial state values depend on this.
final bool hostIsMacOS = !kIsWeb && Platform.isMacOS;
late MethodChannel channel;
late List<MethodCall> outgoingCalls;
/// Intercept outbound calls AFTER the service's handler is installed.
/// We store a reference so outbound invokeMethod calls can be
/// captured while inbound handlePlatformMessage calls are routed
/// to the service's handler.
Future<Object?> Function(MethodCall call)? outgoingResponder;
Future<void> sendInputMonitoringStateChanged({
required String state,
}) async {
const codec = StandardMethodCodec();
final encoded = codec.encodeMethodCall(
MethodCall(methodInputMonitoringStateChanged, <String, dynamic>{
'state': state,
}),
);
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(channel.name, encoded, (_) {});
}
Future<void> sendLocalNetworkStateChanged({
required String state,
}) async {
const codec = StandardMethodCodec();
final encoded = codec.encodeMethodCall(
MethodCall(methodLocalNetworkStateChanged, <String, dynamic>{
'state': state,
}),
);
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(channel.name, encoded, (_) {});
}
setUp(() {
channel = const MethodChannel(macOSPermissionsChannelName);
outgoingCalls = <MethodCall>[];
outgoingResponder = null;
// The mock handler captures outbound calls AND delegates inbound
// platform messages to the service handler when set.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
outgoingCalls.add(call);
final r = outgoingResponder;
if (r != null) return r(call);
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
// ===========================================================================
// Initial state
// ===========================================================================
test(
'SWE4-UV / SRS-297: a fresh service exposes a deterministic initial '
'inputMonitoringState that matches the host platform',
() {
final svc = MacOSPermissionsService(channel: channel);
if (hostIsMacOS) {
// On macOS the service hasn't queried the native side yet.
expect(svc.inputMonitoringState.value, MacOSPermissionState.unknown);
expect(svc.pttCapabilityState.value, 'L0Focused');
} else {
// On non-macOS the static check short-circuits to granted.
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
expect(svc.pttCapabilityState.value, 'L1MacOSEventTap');
}
svc.dispose();
},
);
// ===========================================================================
// Input Monitoring — inbound state changes
// ===========================================================================
test(
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
'state=Granted transitions inputMonitoringState and PTT capability',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
// Drive away from the initial value first.
await sendInputMonitoringStateChanged(state: 'Denied');
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
expect(svc.pttCapabilityState.value, 'L0Focused');
var notified = 0;
void listener() => notified++;
svc.inputMonitoringState.addListener(listener);
await sendInputMonitoringStateChanged(state: 'Granted');
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
expect(svc.pttCapabilityState.value, 'L1MacOSEventTap');
expect(notified, greaterThanOrEqualTo(1));
svc.inputMonitoringState.removeListener(listener);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
'state=Denied transitions to denied and L0Focused',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await sendInputMonitoringStateChanged(state: 'Denied');
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
expect(svc.pttCapabilityState.value, 'L0Focused');
svc.dispose();
},
);
test(
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
'state=NotDetermined transitions to notDetermined and L0Focused',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await sendInputMonitoringStateChanged(state: 'NotDetermined');
expect(
svc.inputMonitoringState.value,
MacOSPermissionState.notDetermined,
);
expect(svc.pttCapabilityState.value, 'L0Focused');
svc.dispose();
},
);
test(
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
'malformed state parses to unknown and L0Focused',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
// Start from a known baseline.
await sendInputMonitoringStateChanged(state: 'Granted');
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
await sendInputMonitoringStateChanged(state: 'Bogus');
expect(svc.inputMonitoringState.value, MacOSPermissionState.unknown);
expect(svc.pttCapabilityState.value, 'L0Focused');
svc.dispose();
},
);
// ===========================================================================
// Local Network — inbound state changes
// ===========================================================================
test(
'SWE4-UV / SRS-300: inbound localNetworkStateChanged with '
'state=Granted transitions localNetworkState',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await sendLocalNetworkStateChanged(state: 'Granted');
expect(svc.localNetworkState.value, MacOSLocalNetworkState.granted);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: inbound localNetworkStateChanged with '
'state=Denied transitions localNetworkState',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await sendLocalNetworkStateChanged(state: 'Denied');
expect(svc.localNetworkState.value, MacOSLocalNetworkState.denied);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: inbound localNetworkStateChanged with '
'state=Unsupported transitions localNetworkState',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await sendLocalNetworkStateChanged(state: 'Unsupported');
expect(
svc.localNetworkState.value,
MacOSLocalNetworkState.unsupported,
);
svc.dispose();
},
);
// ===========================================================================
// Outbound method calls
// ===========================================================================
test(
'SWE4-UV / SRS-297: requestInputMonitoring() emits outbound '
'requestInputMonitoring MethodCall; returns the platform response',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodRequestInputMonitoring) {
return 'Granted';
}
return null;
};
final result = await svc.requestInputMonitoring();
expect(
outgoingCalls.where((c) => c.method == methodRequestInputMonitoring),
hasLength(1),
reason: 'requestInputMonitoring must be called',
);
expect(result, MacOSPermissionState.granted);
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
expect(svc.pttCapabilityState.value, 'L1MacOSEventTap');
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: triggerLocalNetworkPrompt() emits outbound '
'triggerLocalNetworkPrompt MethodCall; returns the platform response',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodTriggerLocalNetworkPrompt) {
return 'Granted';
}
return null;
};
final result = await svc.triggerLocalNetworkPrompt();
expect(
outgoingCalls
.where((c) => c.method == methodTriggerLocalNetworkPrompt),
hasLength(1),
reason: 'triggerLocalNetworkPrompt must be called',
);
expect(result, MacOSLocalNetworkState.granted);
svc.dispose();
},
);
test(
'SWE4-UV / SysRS-166: requestNotifications() emits outbound '
'requestNotifications MethodCall; returns the platform response',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodRequestNotifications) {
return 'Granted';
}
return null;
};
final result = await svc.requestNotifications();
expect(
outgoingCalls.where((c) => c.method == methodRequestNotifications),
hasLength(1),
reason: 'requestNotifications must be called',
);
expect(result, MacOSPermissionState.granted);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-297: openInputMonitoringSettings() emits outbound '
'openInputMonitoringSettings MethodCall',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await svc.openInputMonitoringSettings();
final calls = outgoingCalls
.where((c) => c.method == methodOpenInputMonitoringSettings)
.toList();
expect(calls, hasLength(1));
svc.dispose();
},
);
// ===========================================================================
// Lifecycle
// ===========================================================================
test(
'SWE4-UV / SRS-297: start() is idempotent — calling it twice '
'does not double-register the handler',
() async {
final svc = MacOSPermissionsService(channel: channel)
..start()
..start();
var notified = 0;
void listener() => notified++;
svc.inputMonitoringState.addListener(listener);
await sendInputMonitoringStateChanged(state: 'Denied');
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
expect(notified, 1);
svc.inputMonitoringState.removeListener(listener);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-297: stop() removes the handler — subsequent '
'inbound messages have no effect on state',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await sendInputMonitoringStateChanged(state: 'Denied');
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
svc.stop();
await sendInputMonitoringStateChanged(state: 'Granted');
expect(
svc.inputMonitoringState.value,
MacOSPermissionState.denied,
reason: 'state must be frozen after stop()',
);
},
);
// ===========================================================================
// Non-macOS short-circuit (channel is null)
// ===========================================================================
test(
'SWE4-UV / SRS-297: null-channel short-circuit — on non-macOS hosts '
'the constructor seeds to safe defaults; on macOS hosts, passing '
'channel:null still creates a real channel because _isMacOS is true',
() async {
if (!hostIsMacOS) {
// On non-macOS: the constructor's `_isMacOS` branch is false,
// so `_channel` stays null. All methods return safe defaults
// without touching any channel.
final svc = MacOSPermissionsService(channel: null);
expect(
svc.inputMonitoringState.value,
MacOSPermissionState.granted,
);
expect(
svc.localNetworkState.value,
MacOSLocalNetworkState.unsupported,
);
expect(
svc.notificationState.value,
MacOSPermissionState.granted,
);
final priorOutgoing = outgoingCalls.length;
await svc.requestInputMonitoring();
await svc.triggerLocalNetworkPrompt();
await svc.requestNotifications();
await svc.openInputMonitoringSettings();
expect(
outgoingCalls.length,
priorOutgoing,
reason: 'no outbound calls when platform is non-macOS',
);
svc.start();
svc.stop();
svc.dispose();
} else {
// On macOS: even with channel: null, the constructor creates
// a MethodChannel because _isMacOS is true. This is the
// correct production behaviour — on macOS the service always
// has a channel. The short-circuit path is unreachable on
// macOS by design.
final svc = MacOSPermissionsService(channel: null);
// The service has a non-null _channel, so methods will attempt
// to invoke the channel (which has no native handler in tests).
// Verify the service doesn't throw and returns a value.
final result = await svc.requestInputMonitoring();
expect(result, isNotNull);
svc.dispose();
}
},
);
// ===========================================================================
// Channel error handling
// ===========================================================================
test(
'SWE4-UV / SRS-297: requestInputMonitoring() returns cached state '
'when the channel throws',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
// Seed a known state via inbound.
await sendInputMonitoringStateChanged(state: 'Denied');
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
// Make the platform stub throw.
outgoingResponder = (call) async {
if (call.method == methodRequestInputMonitoring) {
throw PlatformException(code: 'unavailable');
}
return null;
};
final result = await svc.requestInputMonitoring();
expect(
result,
MacOSPermissionState.denied,
reason:
'on channel failure requestInputMonitoring must return cached state',
);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: triggerLocalNetworkPrompt() returns cached state '
'when the channel throws',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodTriggerLocalNetworkPrompt) {
throw PlatformException(code: 'unavailable');
}
return null;
};
final result = await svc.triggerLocalNetworkPrompt();
// Returns the cached state without crashing.
expect(result, isNotNull);
svc.dispose();
},
);
}
@@ -493,6 +493,7 @@ void main() {
framesSent: 1, framesSent: 1,
framesReceived: 0, framesReceived: 0,
pttActive: true, pttActive: true,
inputLevel: -30.0,
), ),
), ),
); );
+42 -2
View File
@@ -253,49 +253,87 @@ pub enum SessionEvent {
}, },
/// Audio route changed (speaker/earpiece/BT/wired headset). /// Audio route changed (speaker/earpiece/BT/wired headset).
AudioRouteChanged { AudioRouteChanged {
/// New audio output route.
route: AudioRoute, route: AudioRoute,
}, },
/// A client moved to a different channel.
ClientMoved { ClientMoved {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Destination channel.
new_channel_id: u64, new_channel_id: u64,
}, },
/// A new client connected.
ClientJoined { ClientJoined {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Channel the client joined.
channel_id: u64, channel_id: u64,
/// Display nickname.
name: String, name: String,
/// Microphone muted state.
input_muted: bool, input_muted: bool,
/// Speaker muted state.
output_muted: bool, output_muted: bool,
/// Whether this is a server query (bot) client.
is_server_query: bool, is_server_query: bool,
/// Client's talk power value.
talk_power: i32, talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool, talk_power_granted: bool,
}, },
/// A client disconnected.
ClientLeft { ClientLeft {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Display nickname at time of disconnect.
name: String, name: String,
}, },
/// Client properties changed.
ClientUpdated { ClientUpdated {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Microphone muted state.
input_muted: bool, input_muted: bool,
/// Speaker muted state.
output_muted: bool, output_muted: bool,
/// Whether this is a server query (bot) client.
is_server_query: bool, is_server_query: bool,
/// Client's talk power value.
talk_power: i32, talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool, talk_power_granted: bool,
}, },
/// A new channel appeared.
ChannelAdded { ChannelAdded {
/// Unique channel identifier.
id: u64, id: u64,
/// Parent channel ID.
parent: u64, parent: u64,
/// Channel name.
name: String, name: String,
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
order: i64, order: i64,
/// Whether the channel requires a password.
has_password: bool, has_password: bool,
/// Talk power required to speak, or `None` when unrestricted.
needed_talk_power: Option<i32>, needed_talk_power: Option<i32>,
}, },
/// A channel was deleted.
ChannelRemoved { ChannelRemoved {
/// Channel identifier.
id: u64, id: u64,
}, },
/// Channel properties changed.
ChannelUpdated { ChannelUpdated {
/// Unique channel identifier.
id: u64, id: u64,
/// Channel name.
name: String, name: String,
/// Whether the channel requires a password.
has_password: bool, has_password: bool,
/// Talk power required to speak, or `None` when unrestricted.
needed_talk_power: Option<i32>, needed_talk_power: Option<i32>,
}, },
} }
@@ -1307,8 +1345,8 @@ impl ChanoraSession {
Ok(()) Ok(())
} }
/// Read audio engine statistics: (frames_sent, frames_received, transmit_active). /// Read audio engine statistics: (frames_sent, frames_received, transmit_active, input_level_dbfs).
pub async fn audio_stats(&self) -> Result<(u32, u32, bool), CoreError> { pub async fn audio_stats(&self) -> Result<(u32, u32, bool, f32), CoreError> {
let guard = self.inner.lock().await; let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?; let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?; let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
@@ -1316,6 +1354,7 @@ impl ChanoraSession {
audio.frames_sent(), audio.frames_sent(),
audio.frames_received(), audio.frames_received(),
audio.transmit_active(), audio.transmit_active(),
audio.input_level(),
)) ))
} }
@@ -2399,6 +2438,7 @@ async fn supervisor_loop(ctx: SupervisorContext) {
/// the UI would render. Two snapshots with identical channel /// the UI would render. Two snapshots with identical channel
/// memberships, names, and orderings produce the same signature; /// memberships, names, and orderings produce the same signature;
/// any in-channel move, rename, or reorder produces a different one. /// any in-channel move, rename, or reorder produces a different one.
#[cfg(test)]
fn snapshot_signature(snap: &ServerSnapshot) -> u64 { fn snapshot_signature(snap: &ServerSnapshot) -> u64 {
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
+6
View File
@@ -29,6 +29,7 @@ audiopus = "0.3.0-rc.0"
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] } tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
rustfft = "6.2.0" rustfft = "6.2.0"
crossbeam = { version = "0.8", default-features = false, features = ["alloc", "crossbeam-queue"] }
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies] [target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
# Desktop audio I/O for Windows capture/playback and Linux capture. # Desktop audio I/O for Windows capture/playback and Linux capture.
@@ -72,6 +73,11 @@ ndk-context = "0.1"
# - deduplicated macro impls # - deduplicated macro impls
# - PowerSavingOffloaded PerformanceMode variant # - PowerSavingOffloaded PerformanceMode variant
oboe = { git = "https://github.com/EdisonJwa/oboe-rs", rev = "a14f9b83ecea8c93f5a692f2ee7808445b938c35" } oboe = { git = "https://github.com/EdisonJwa/oboe-rs", rev = "a14f9b83ecea8c93f5a692f2ee7808445b938c35" }
# Safe slice reinterpret for the oboe stereo output callback.
# bytemuck::cast_slice_mut replaces the raw-pointer cast from
# `&mut [(f32, f32)]` to `&mut [f32]` with a provenance-correct
# and UB-free transmute backed by `NoUninit`.
bytemuck = { version = "1", features = ["derive"] }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
# Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices # Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices
+82 -53
View File
@@ -44,6 +44,7 @@ use std::sync::{Arc, Mutex};
use audiopus::coder::Encoder as OpusEncoder; use audiopus::coder::Encoder as OpusEncoder;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::audio_event_queue::{AudioCommand, AudioEventQueue};
use crate::mobile_voice_backend::{ use crate::mobile_voice_backend::{
clear_android_audio_diagnostics, latency_tier_for, next_input_preset_after, clear_android_audio_diagnostics, latency_tier_for, next_input_preset_after,
next_sharing_mode_after, publish_android_audio_diagnostics, AchievedInputPreset, next_sharing_mode_after, publish_android_audio_diagnostics, AchievedInputPreset,
@@ -63,7 +64,7 @@ use oboe::{
AudioInputCallback, AudioInputStreamSafe, AudioOutputCallback, AudioOutputStreamSafe, AudioInputCallback, AudioInputStreamSafe, AudioOutputCallback, AudioOutputStreamSafe,
AudioStream, AudioStreamAsync, AudioStreamBase, AudioStreamBuilder, AudioStreamSafe, AudioStream, AudioStreamAsync, AudioStreamBase, AudioStreamBuilder, AudioStreamSafe,
DataCallbackResult, Input as OboeInput, InputPreset, Mono, Output as OboeOutput, DataCallbackResult, Input as OboeInput, InputPreset, Mono, Output as OboeOutput,
PerformanceMode, SessionId, SharingMode, Usage, PerformanceMode, SessionId, SharingMode, Stereo, Usage,
}; };
use crate::processor::AudioProcessor; use crate::processor::AudioProcessor;
@@ -518,14 +519,14 @@ impl AudioInputCallback for InputCallback {
// //
// Mirrors the iOS VPIO render callback. Pulls mixed 48 kHz stereo f32 // Mirrors the iOS VPIO render callback. Pulls mixed 48 kHz stereo f32
// from `AudioHandler::fill_buffer`, applies output gain + mute, and // from `AudioHandler::fill_buffer`, applies output gain + mute, and
// writes mono i16 to the Oboe output buffer. // writes stereo f32 directly to the Oboe output buffer.
struct OutputCallback { struct OutputCallback {
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>, 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,
scratch: Arc<Mutex<Vec<f32>>>,
render_reference: Arc<RenderReferenceBuffer>, render_reference: Arc<RenderReferenceBuffer>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>, audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
pending_render_ref: [f32; crate::frame::FRAME_10MS_SAMPLES], pending_render_ref: [f32; crate::frame::FRAME_10MS_SAMPLES],
@@ -533,51 +534,54 @@ struct OutputCallback {
} }
impl AudioOutputCallback for OutputCallback { impl AudioOutputCallback for OutputCallback {
type FrameType = (i16, Mono); type FrameType = (f32, Stereo);
fn on_audio_ready( fn on_audio_ready(
&mut self, &mut self,
_stream: &mut dyn AudioOutputStreamSafe, _stream: &mut dyn AudioOutputStreamSafe,
frames: &mut [i16], frames: &mut [(f32, f32)],
) -> DataCallbackResult { ) -> DataCallbackResult {
let _ = catch_unwind(AssertUnwindSafe(|| { let _ = catch_unwind(AssertUnwindSafe(|| {
let needed = frames.len() * 2; // stereo let buf: &mut [f32] =
let scratch = &mut self.scratch.lock().unwrap(); bytemuck::cast_slice_mut::<(f32, f32), f32>(frames);
if scratch.len() < needed { for s in buf.iter_mut() {
scratch.resize(needed, 0.0);
} else {
for s in &mut scratch[..needed] {
*s = 0.0; *s = 0.0;
} }
} for cmd in self.event_consumer.drain_controls() {
match self.handler.try_lock() { match cmd {
Ok(mut h) => { AudioCommand::SetVolume(id, vol) => {
let _ = h.fill_buffer(&mut scratch[..needed]); if let Some(q) = self.handler.get_mut_queues().get_mut(&id) {
} q.volume = vol;
Err(std::sync::TryLockError::WouldBlock) => {}
Err(std::sync::TryLockError::Poisoned(e)) => {
warn!(
target: "chanora_audio",
"AudioHandler mutex poisoned: {}",
e
);
} }
} }
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);
let _ = crate::voice_render::downmix_stereo_f32_to_mono_i16( if muted {
&scratch[..needed], for s in buf.iter_mut() {
frames, *s = 0.0;
gain, }
muted, } else if gain != 1.0 {
); for s in buf.iter_mut() {
*s *= gain;
}
}
self.audio_processing_stats self.audio_processing_stats
.update_render(crate::frame::dbfs(&scratch[..needed]), frames.len() as u32); .update_render(crate::frame::dbfs(buf), frames.len() as u32);
// Accumulate the full render callback into 10 ms mono chunks so for chunk in buf.chunks_exact(2) {
// AEC sees consistent reference timing even when output callbacks
// are shorter or longer than 10 ms.
for chunk in scratch[..needed].chunks_exact(2) {
self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 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 {
@@ -677,8 +681,6 @@ impl AndroidVoiceUnit {
.map_err(|e| BackendError::OpenFailed(format!("capture state init: {e}")))?, .map_err(|e| BackendError::OpenFailed(format!("capture state init: {e}")))?,
)); ));
let scratch = Arc::new(Mutex::new(Vec::with_capacity(8192)));
// --- Open input stream (SDD-112) --------------------------- // --- Open input stream (SDD-112) ---------------------------
let input_builder = AudioStreamBuilder::default() let input_builder = AudioStreamBuilder::default()
.set_direction::<OboeInput>() .set_direction::<OboeInput>()
@@ -766,8 +768,8 @@ impl AndroidVoiceUnit {
let output_builder = AudioStreamBuilder::default() let output_builder = AudioStreamBuilder::default()
.set_direction::<OboeOutput>() .set_direction::<OboeOutput>()
.set_sample_rate(cfg.sample_rate as i32) .set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::<Mono>() .set_channel_count::<Stereo>()
.set_format::<i16>() .set_format::<f32>()
.set_performance_mode(if cfg.request_low_latency { .set_performance_mode(if cfg.request_low_latency {
PerformanceMode::LowLatency PerformanceMode::LowLatency
} else { } else {
@@ -778,16 +780,21 @@ impl AndroidVoiceUnit {
} else { } else {
SharingMode::Shared SharingMode::Shared
}) })
.set_usage(Usage::VoiceCommunication) // Usage::Game avoids forcing the Legacy (OpenSL ES) data path
.set_content_type(oboe::ContentType::Speech); // that Usage::VoiceCommunication triggers on most devices.
// Android audio routing is already handled by
// AudioManager.MODE_IN_COMMUNICATION on the Flutter side.
.set_usage(Usage::Game)
.set_content_type(oboe::ContentType::Sonification);
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 output_cb = OutputCallback { let output_cb = OutputCallback {
handler: params.handler.clone(), 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(),
scratch: scratch.clone(),
render_reference: render_ref_for_output, render_reference: render_ref_for_output,
audio_processing_stats: audio_processing_stats.clone(), audio_processing_stats: audio_processing_stats.clone(),
pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES], pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES],
@@ -806,20 +813,41 @@ impl AndroidVoiceUnit {
Self::open_output_fallback( Self::open_output_fallback(
cfg, cfg,
&event_tx, &event_tx,
params.handler.clone(), 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(),
scratch.clone(),
render_ref_buf, render_ref_buf,
)? )?
} }
}; };
let output_frames_per_burst = output_stream.get_frames_per_burst();
if output_frames_per_burst > 0 {
let desired = output_frames_per_burst * 2;
match output_stream.set_buffer_size_in_frames(desired) {
Ok(actual) => {
debug!(
target: "chanora_audio",
desired,
actual,
"android: output buffer size tuned"
);
}
Err(e) => {
warn!(
target: "chanora_audio",
error = ?e,
"android: output buffer size tuning failed; using device default"
);
}
}
}
let output_perf = perf_from_oboe(output_stream.get_performance_mode()); let output_perf = perf_from_oboe(output_stream.get_performance_mode());
let output_share = share_from_oboe(output_stream.get_sharing_mode()); let output_share = share_from_oboe(output_stream.get_sharing_mode());
let output_sample_rate = output_stream.get_sample_rate(); let output_sample_rate = output_stream.get_sample_rate();
let output_frames_per_burst = output_stream.get_frames_per_burst();
// SDD-112 / SRS-210: structured "stream opened" event with // SDD-112 / SRS-210: structured "stream opened" event with
// achieved values. No PII; only platform-reported scalars. // achieved values. No PII; only platform-reported scalars.
@@ -1038,19 +1066,19 @@ impl AndroidVoiceUnit {
fn open_output_fallback( fn open_output_fallback(
cfg: &AndroidVoiceStreamConfig, cfg: &AndroidVoiceStreamConfig,
event_tx: &BackendEventTx, event_tx: &BackendEventTx,
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>, 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>,
scratch: Arc<Mutex<Vec<f32>>>,
render_reference: Arc<RenderReferenceBuffer>, render_reference: Arc<RenderReferenceBuffer>,
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> { ) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
let cb = OutputCallback { let cb = OutputCallback {
handler, handler,
event_consumer,
output_gain, output_gain,
output_muted, output_muted,
event_tx: event_tx.clone(), event_tx: event_tx.clone(),
scratch,
render_reference, render_reference,
audio_processing_stats, audio_processing_stats,
pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES], pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES],
@@ -1059,12 +1087,13 @@ impl AndroidVoiceUnit {
let builder = AudioStreamBuilder::default() let builder = AudioStreamBuilder::default()
.set_direction::<OboeOutput>() .set_direction::<OboeOutput>()
.set_sample_rate(cfg.sample_rate as i32) .set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::<Mono>() .set_channel_count::<Stereo>()
.set_format::<i16>() .set_format::<f32>()
.set_performance_mode(PerformanceMode::LowLatency) .set_performance_mode(PerformanceMode::LowLatency)
.set_sharing_mode(SharingMode::Shared) .set_sharing_mode(SharingMode::Shared)
.set_usage(Usage::VoiceCommunication) // Same Usage::Game rationale as primary output builder above.
.set_content_type(oboe::ContentType::Speech) .set_usage(Usage::Game)
.set_content_type(oboe::ContentType::Sonification)
.set_callback(cb); .set_callback(cb);
builder builder
.open_stream() .open_stream()
@@ -0,0 +1,120 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use chanora_protocol::InAudioBuf;
use crossbeam::queue::ArrayQueue;
use crate::engine::SessionAudioId;
const PACKET_QUEUE_CAPACITY: usize = 100;
const CONTROL_QUEUE_CAPACITY: usize = 32;
/// A raw inbound voice packet waiting to be inserted into AudioHandler.
pub struct AudioPacket {
/// Client whose TeamSpeak audio packet this belongs to.
pub client_id: SessionAudioId,
/// Raw inbound TeamSpeak audio payload accepted by AudioHandler::handle_packet.
pub data: InAudioBuf,
}
/// Control commands from the main thread to the audio callback.
pub enum AudioCommand {
/// Set a client's output volume.
SetVolume(SessionAudioId, f32),
/// Remove a client's decode queue.
// TODO: Wire to client disconnect path; handled in callback but no
// producer currently pushes this command.
RemoveClient(SessionAudioId),
}
/// Lock-free bridge between the inbound forwarder / main thread and the
/// audio callback. The callback owns the consumer halves.
pub struct AudioEventQueue {
/// Bounded lossy queue for raw voice packets. On overflow, the push
/// fails and the packet is dropped (counted via `packets_dropped`).
/// Capacity: 100 packets (~2 seconds at 50pps, far more than needed).
pub packet_queue: ArrayQueue<AudioPacket>,
/// Bounded reliable queue for control commands (volume, client removal).
/// On overflow, the caller retries. Capacity: 32 commands.
pub control_queue: ArrayQueue<AudioCommand>,
/// Atomic counter for dropped packets (for diagnostics).
pub packets_dropped: AtomicU64,
}
impl AudioEventQueue {
/// Create the Android audio event bridge with fixed queue capacities.
pub fn new() -> Arc<Self> {
Arc::new(Self {
packet_queue: ArrayQueue::new(PACKET_QUEUE_CAPACITY),
control_queue: ArrayQueue::new(CONTROL_QUEUE_CAPACITY),
packets_dropped: AtomicU64::new(0),
})
}
/// Create a producer handle sharing this queue.
pub fn producer(queue: &Arc<Self>) -> AudioEventProducer {
AudioEventProducer {
queue: Arc::clone(queue),
}
}
/// Create a consumer handle sharing this queue.
pub fn consumer(queue: &Arc<Self>) -> AudioEventConsumer {
AudioEventConsumer {
queue: Arc::clone(queue),
}
}
}
/// Producer side used by the inbound forwarder and engine control methods.
#[derive(Clone)]
pub struct AudioEventProducer {
queue: Arc<AudioEventQueue>,
}
impl AudioEventProducer {
/// Push a raw voice packet, incrementing the drop counter if full.
pub fn push_packet(&self, packet: AudioPacket) -> Result<(), AudioPacket> {
self.queue.packet_queue.push(packet).map_err(|packet| {
self.queue.packets_dropped.fetch_add(1, Ordering::Relaxed);
packet
})
}
/// Push a control command, returning it unchanged if the queue is full.
pub fn push_control(&self, cmd: AudioCommand) -> Result<(), AudioCommand> {
self.queue.control_queue.push(cmd)
}
/// Shared queue backing this producer.
pub fn queue(&self) -> Arc<AudioEventQueue> {
Arc::clone(&self.queue)
}
}
/// Consumer side used by the Android output callback.
pub struct AudioEventConsumer {
queue: Arc<AudioEventQueue>,
}
impl AudioEventConsumer {
/// Pop up to `cap` queued packets.
pub fn drain_packets(&self, cap: usize) -> impl Iterator<Item = AudioPacket> + '_ {
let mut drained = 0;
std::iter::from_fn(move || {
if drained >= cap {
return None;
}
let packet = self.queue.packet_queue.pop();
if packet.is_some() {
drained += 1;
}
packet
})
}
/// Pop all currently queued controls.
pub fn drain_controls(&self) -> impl Iterator<Item = AudioCommand> + '_ {
std::iter::from_fn(move || self.queue.control_queue.pop())
}
}
@@ -404,6 +404,19 @@ impl Default for SharedAudioProcessingStats {
} }
impl SharedAudioProcessingStats { impl SharedAudioProcessingStats {
/// Store the raw input dBFS level (desktop capture path).
/// Mobile platforms use [`Self::update_capture`] instead, which
/// also records VAD state; this lighter method is for the cpal
/// capture path that has no VAD pipeline.
pub fn set_input_dbfs(&self, dbfs: f32) {
self.input_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
}
/// Read the current input dBFS level.
pub fn input_dbfs(&self) -> f32 {
f32::from_bits(self.input_dbfs.load(Ordering::Relaxed))
}
/// Store capture levels and VAD state. /// Store capture levels and VAD state.
pub fn update_capture( pub fn update_capture(
&self, &self,
+77 -38
View File
@@ -52,6 +52,8 @@ use tsclientlib::audio::AudioHandler;
use chanora_protocol::{InboundVoice, OutPacket}; use chanora_protocol::{InboundVoice, OutPacket};
#[cfg(target_os = "android")]
use crate::audio_event_queue::{AudioCommand, AudioEventQueue, AudioPacket};
use crate::AudioError; use crate::AudioError;
#[cfg(all( #[cfg(all(
@@ -305,12 +307,15 @@ pub struct AudioEngine {
output_muted: Arc<AtomicBool>, output_muted: Arc<AtomicBool>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>, audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>, audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
#[cfg(not(target_os = "android"))]
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>, audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] #[cfg(target_os = "android")]
audio_event_producer: crate::audio_event_queue::AudioEventProducer,
#[cfg(target_os = "android")]
voice_out_tx: mpsc::Sender<OutPacket>, voice_out_tx: mpsc::Sender<OutPacket>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] #[cfg(target_os = "android")]
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>, voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] #[cfg(target_os = "android")]
mic_gain: f32, mic_gain: f32,
// Streams must be dropped to stop audio. Both are `!Send` because // Streams must be dropped to stop audio. Both are `!Send` because
// cpal's Stream isn't Send on some backends; we keep them in an // cpal's Stream isn't Send on some backends; we keep them in an
@@ -482,7 +487,7 @@ impl AudioEngine {
voice_out_tx: mpsc::Sender<OutPacket>, voice_out_tx: mpsc::Sender<OutPacket>,
transmit_gate: crate::ptt::AudioTransmitGate, transmit_gate: crate::ptt::AudioTransmitGate,
frames_sent: Arc<AtomicU32>, frames_sent: Arc<AtomicU32>,
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>, event_producer: crate::audio_event_queue::AudioEventProducer,
output_gain: Arc<AtomicU32>, output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>, output_muted: Arc<AtomicBool>,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>, voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
@@ -552,7 +557,8 @@ impl AudioEngine {
transmit_active: transmit_gate.flag_arc(), transmit_active: transmit_gate.flag_arc(),
frames_sent: frames_sent.clone(), frames_sent: frames_sent.clone(),
mic_gain, mic_gain,
handler: audio_handler.clone(), handler: AudioHandler::new(),
event_producer: event_producer.clone(),
output_gain: output_gain.clone(), output_gain: output_gain.clone(),
output_muted: output_muted.clone(), output_muted: output_muted.clone(),
voice_activity_selector: voice_activity_selector.clone(), voice_activity_selector: voice_activity_selector.clone(),
@@ -572,7 +578,7 @@ impl AudioEngine {
voice_out_tx.clone(), voice_out_tx.clone(),
transmit_gate.clone(), transmit_gate.clone(),
frames_sent.clone(), frames_sent.clone(),
audio_handler.clone(), event_producer.clone(),
output_gain.clone(), output_gain.clone(),
output_muted.clone(), output_muted.clone(),
voice_activity_selector.clone(), voice_activity_selector.clone(),
@@ -832,6 +838,7 @@ impl AudioEngine {
transmit_flag_for_capture, transmit_flag_for_capture,
frames_sent.clone(), frames_sent.clone(),
cfg.mic_gain, cfg.mic_gain,
audio_processing_stats.clone(),
); );
let (input_stream, capture_active) = match capture_result { let (input_stream, capture_active) = match capture_result {
Ok(s) => (Some(s), true), Ok(s) => (Some(s), true),
@@ -1007,8 +1014,8 @@ 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());
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> = let event_queue = AudioEventQueue::new();
Arc::new(Mutex::new(AudioHandler::new())); let event_producer = AudioEventQueue::producer(&event_queue);
if !cfg.mobile_voice_preset { if !cfg.mobile_voice_preset {
return Err(AudioError::Backend( return Err(AudioError::Backend(
@@ -1069,7 +1076,8 @@ impl AudioEngine {
transmit_active: transmit_flag_for_capture, transmit_active: transmit_flag_for_capture,
frames_sent: frames_sent.clone(), frames_sent: frames_sent.clone(),
mic_gain: cfg.mic_gain, mic_gain: cfg.mic_gain,
handler: audio_handler.clone(), handler: AudioHandler::new(),
event_producer: event_producer.clone(),
output_gain: output_gain.clone(), output_gain: output_gain.clone(),
output_muted: output_muted.clone(), output_muted: output_muted.clone(),
voice_activity_selector: cfg.voice_activity_selector.clone(), voice_activity_selector: cfg.voice_activity_selector.clone(),
@@ -1103,7 +1111,7 @@ impl AudioEngine {
voice_out_tx.clone(), voice_out_tx.clone(),
transmit_gate.clone(), transmit_gate.clone(),
frames_sent.clone(), frames_sent.clone(),
audio_handler.clone(), event_producer.clone(),
output_gain.clone(), output_gain.clone(),
output_muted.clone(), output_muted.clone(),
cfg.voice_activity_selector.clone(), cfg.voice_activity_selector.clone(),
@@ -1151,7 +1159,7 @@ impl AudioEngine {
let capture_active = true; let capture_active = true;
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel(); let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.clone(); let event_producer_for_task = event_producer.clone();
let frames_received_for_task = frames_received.clone(); let frames_received_for_task = frames_received.clone();
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
@@ -1164,10 +1172,8 @@ impl AudioEngine {
match item { match item {
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 packet = AudioPacket { client_id: id, data: v.packet };
if let Err(e) = h.handle_packet(id, v.packet) { if event_producer_for_task.push_packet(packet).is_ok() {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
frames_received_for_task.fetch_add(1, Ordering::Relaxed); frames_received_for_task.fetch_add(1, Ordering::Relaxed);
} }
} }
@@ -1186,7 +1192,7 @@ impl AudioEngine {
output_muted, output_muted,
audio_processing_config, audio_processing_config,
audio_processing_stats, audio_processing_stats,
audio_handler, audio_event_producer: event_producer,
voice_out_tx, voice_out_tx,
voice_activity_selector: cfg.voice_activity_selector.clone(), voice_activity_selector: cfg.voice_activity_selector.clone(),
mic_gain: cfg.mic_gain, mic_gain: cfg.mic_gain,
@@ -1308,9 +1314,6 @@ impl AudioEngine {
audio_processing_config, audio_processing_config,
audio_processing_stats, audio_processing_stats,
audio_handler, audio_handler,
voice_out_tx,
voice_activity_selector: cfg.voice_activity_selector.clone(),
mic_gain: cfg.mic_gain,
_ios_voice_backend: Mutex::new(Some(ios_voice_backend)), _ios_voice_backend: Mutex::new(Some(ios_voice_backend)),
shutdown_tx: Some(shutdown_tx), shutdown_tx: Some(shutdown_tx),
capture_active, capture_active,
@@ -1470,7 +1473,8 @@ impl AudioEngine {
transmit_active: self.transmit_gate.flag_arc(), transmit_active: self.transmit_gate.flag_arc(),
frames_sent: self.frames_sent.clone(), frames_sent: self.frames_sent.clone(),
mic_gain: self.mic_gain, mic_gain: self.mic_gain,
handler: self.audio_handler.clone(), handler: AudioHandler::new(),
event_producer: self.audio_event_producer.clone(),
output_gain: self.output_gain.clone(), output_gain: self.output_gain.clone(),
output_muted: self.output_muted.clone(), output_muted: self.output_muted.clone(),
voice_activity_selector: self.voice_activity_selector.clone(), voice_activity_selector: self.voice_activity_selector.clone(),
@@ -1491,7 +1495,7 @@ impl AudioEngine {
self.voice_out_tx.clone(), self.voice_out_tx.clone(),
self.transmit_gate.clone(), self.transmit_gate.clone(),
self.frames_sent.clone(), self.frames_sent.clone(),
self.audio_handler.clone(), self.audio_event_producer.clone(),
self.output_gain.clone(), self.output_gain.clone(),
self.output_muted.clone(), self.output_muted.clone(),
self.voice_activity_selector.clone(), self.voice_activity_selector.clone(),
@@ -1594,6 +1598,11 @@ impl AudioEngine {
self.frames_received.load(Ordering::Relaxed) self.frames_received.load(Ordering::Relaxed)
} }
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
pub fn input_level(&self) -> f32 {
self.audio_processing_stats.input_dbfs()
}
/// Current audio-processing config snapshot. /// Current audio-processing config snapshot.
pub fn audio_processing_config_snapshot(&self) -> crate::AudioProcessingConfig { pub fn audio_processing_config_snapshot(&self) -> crate::AudioProcessingConfig {
self.audio_processing_config.lock().unwrap().clone() self.audio_processing_config.lock().unwrap().clone()
@@ -1658,6 +1667,27 @@ impl AudioEngine {
/// `0.0..4.0`. /// `0.0..4.0`.
pub fn set_client_volume(&self, client_id: u64, volume: f32) { pub fn set_client_volume(&self, client_id: u64, volume: f32) {
let clamped = volume.clamp(0.0, 4.0); let clamped = volume.clamp(0.0, 4.0);
#[cfg(target_os = "android")]
{
let mut cmd = AudioCommand::SetVolume(SessionAudioId(client_id), clamped);
for _ in 0..64 {
match self.audio_event_producer.push_control(cmd) {
Ok(()) => return,
Err(returned) => {
cmd = returned;
std::thread::yield_now();
}
}
}
tracing::warn!(
target: "chanora_audio",
client_id,
volume = clamped,
"set_client_volume: control queue full after 64 retries — volume not applied"
);
}
#[cfg(not(target_os = "android"))]
{
match self.audio_handler.lock() { match self.audio_handler.lock() {
Ok(mut h) => { Ok(mut h) => {
if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) { if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) {
@@ -1676,6 +1706,7 @@ impl AudioEngine {
} }
} }
} }
}
impl Drop for AudioEngine { impl Drop for AudioEngine {
fn drop(&mut self) { fn drop(&mut self) {
@@ -1692,6 +1723,7 @@ fn try_open_capture(
transmit_active: Arc<AtomicBool>, transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>, frames_sent: Arc<AtomicU32>,
mic_gain: f32, mic_gain: f32,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Result<cpal::Stream, AudioError> { ) -> Result<cpal::Stream, AudioError> {
let in_cfg = in_dev let in_cfg = in_dev
.default_input_config() .default_input_config()
@@ -1727,6 +1759,7 @@ fn try_open_capture(
voice_out_tx, voice_out_tx,
transmit_active, transmit_active,
frames_sent, frames_sent,
audio_processing_stats,
))); )));
let stream = match in_format { let stream = match in_format {
@@ -1778,6 +1811,7 @@ struct CaptureState {
/// capacity so the drain-into-frame path skips the allocator /// capacity so the drain-into-frame path skips the allocator
/// after warmup. Same precedent as `mono_scratch` above. /// after warmup. Same precedent as `mono_scratch` above.
frame_scratch: Vec<f32>, frame_scratch: Vec<f32>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
} }
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
@@ -1790,6 +1824,7 @@ impl CaptureState {
voice_out_tx: mpsc::Sender<OutPacket>, voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>, transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>, frames_sent: Arc<AtomicU32>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Self { ) -> Self {
Self { Self {
encoder, encoder,
@@ -1803,12 +1838,9 @@ impl CaptureState {
voice_out_tx, voice_out_tx,
transmit_active, transmit_active,
frames_sent, frames_sent,
// Generous upper bound for typical cpal periods
// (commonly 256..1024 frames); `clear()` retains the
// backing allocation across callbacks. See struct doc.
mono_scratch: Vec::with_capacity(4096), mono_scratch: Vec::with_capacity(4096),
// Exact upper bound: drain pulls FRAME_SAMPLES at a time.
frame_scratch: Vec::with_capacity(FRAME_SAMPLES), frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
audio_processing_stats,
} }
} }
@@ -1816,17 +1848,8 @@ impl CaptureState {
/// 48 kHz mono frames; encode and send when `transmit_active` /// 48 kHz mono frames; encode and send when `transmit_active`
/// is true (PTT engaged). /// is true (PTT engaged).
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) { fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
if !self.transmit_active.load(Ordering::Relaxed) { // 1. Down-mix to mono (pre-gain). Always performed so the level
// Drain accumulator while muted so we don't pop on PTT release. // meter reflects real mic input even when PTT is released.
self.pcm_accum.clear();
return;
}
// 1. Down-mix to mono + gain.
// Reuse `self.mono_scratch` to avoid a per-callback Vec
// allocation on the realtime audio thread; see struct
// doc and the engine.rs:1389-1397 precedent for why this
// matters for user-perceptible audio popping.
let in_channels = self.in_channels; let in_channels = self.in_channels;
let mic_gain = self.mic_gain; let mic_gain = self.mic_gain;
self.mono_scratch.clear(); self.mono_scratch.clear();
@@ -1834,8 +1857,23 @@ impl CaptureState {
self.mono_scratch.reserve(frame_count); self.mono_scratch.reserve(frame_count);
for frame in buf.chunks(in_channels) { for frame in buf.chunks(in_channels) {
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum(); let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
self.mono_scratch self.mono_scratch.push(sum / frame.len() as f32);
.push((sum / frame.len() as f32) * mic_gain); }
// Measure dBFS from pre-gain samples so the level meter
// reflects the raw mic input, not the amplified signal.
self.audio_processing_stats
.set_input_dbfs(crate::frame::dbfs(&self.mono_scratch));
if mic_gain != 1.0 {
for s in &mut self.mono_scratch {
*s *= mic_gain;
}
}
if !self.transmit_active.load(Ordering::Relaxed) {
self.pcm_accum.clear();
return;
} }
// 2. Resample to 48 kHz if needed. We re-borrow // 2. Resample to 48 kHz if needed. We re-borrow
@@ -2463,6 +2501,7 @@ pub mod bench_seam {
tx, tx,
transmit_active.clone(), transmit_active.clone(),
frames_sent, frames_sent,
Arc::new(crate::SharedAudioProcessingStats::default()),
); );
Self { Self {
state, state,
+2
View File
@@ -28,6 +28,8 @@
#![warn(missing_docs)] #![warn(missing_docs)]
#[cfg(target_os = "android")]
mod audio_event_queue;
pub mod audio_processing; pub mod audio_processing;
pub mod debug_wav; pub mod debug_wav;
mod engine; mod engine;
@@ -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.
#[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.
@@ -78,8 +78,15 @@ pub(crate) struct VoiceAudioParams {
pub frames_sent: Arc<AtomicU32>, pub frames_sent: Arc<AtomicU32>,
/// Pre-encode amplitude scale (1.0 = unity). /// Pre-encode amplitude scale (1.0 = unity).
pub mic_gain: f32, pub mic_gain: f32,
/// AudioHandler owned by the Android output callback.
#[cfg(target_os = "android")]
pub handler: AudioHandler<SessionAudioId>,
/// Producer used by Android engine tasks to feed the output callback.
#[cfg(target_os = "android")]
pub event_producer: crate::audio_event_queue::AudioEventProducer,
/// AudioHandler that inbound decode+mix feeds into; the output /// AudioHandler that inbound decode+mix feeds into; the output
/// callback pulls mixed stereo f32 from it. /// callback pulls mixed stereo f32 from it.
#[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
/// cross-thread read from the realtime audio callback). /// cross-thread read from the realtime audio callback).
+74 -1
View File
@@ -1038,6 +1038,8 @@ pub struct BridgeAudioStats {
pub frames_received: u32, pub frames_received: u32,
/// Current push-to-talk state. /// Current push-to-talk state.
pub ptt_active: bool, pub ptt_active: bool,
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
pub input_level: f32,
} }
/// Bridge route class for P1 audio-processing policy. /// Bridge route class for P1 audio-processing policy.
@@ -1701,49 +1703,87 @@ pub enum BridgeEvent {
}, },
/// Audio route changed (speaker/earpiece/BT/wired). /// Audio route changed (speaker/earpiece/BT/wired).
AudioRouteChanged { AudioRouteChanged {
/// New audio output route.
route: BridgeAudioRoute, route: BridgeAudioRoute,
}, },
/// A client moved to a different channel.
ClientMoved { ClientMoved {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Destination channel.
new_channel_id: u64, new_channel_id: u64,
}, },
/// A new client connected.
ClientJoined { ClientJoined {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Channel the client joined.
channel_id: u64, channel_id: u64,
/// Display nickname.
name: String, name: String,
/// Microphone muted state.
input_muted: bool, input_muted: bool,
/// Speaker muted state.
output_muted: bool, output_muted: bool,
/// True for server query (bot) clients.
is_server_query: bool, is_server_query: bool,
/// Client's talk power value.
talk_power: i32, talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool, talk_power_granted: bool,
}, },
/// A client disconnected.
ClientLeft { ClientLeft {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Display nickname at time of disconnect.
name: String, name: String,
}, },
/// Client properties changed.
ClientUpdated { ClientUpdated {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Microphone muted state.
input_muted: bool, input_muted: bool,
/// Speaker muted state.
output_muted: bool, output_muted: bool,
/// True for server query (bot) clients.
is_server_query: bool, is_server_query: bool,
/// Client's talk power value.
talk_power: i32, talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool, talk_power_granted: bool,
}, },
/// A new channel appeared.
ChannelAdded { ChannelAdded {
/// Unique channel identifier.
id: u64, id: u64,
/// Parent channel ID.
parent: u64, parent: u64,
/// Channel name.
name: String, name: String,
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
order: i64, order: i64,
/// Whether the channel requires a password.
has_password: bool, has_password: bool,
/// Talk power required to speak; `None` means no restriction.
needed_talk_power: Option<i32>, needed_talk_power: Option<i32>,
}, },
/// A channel was deleted.
ChannelRemoved { ChannelRemoved {
/// Channel identifier.
id: u64, id: u64,
}, },
/// Channel properties changed.
ChannelUpdated { ChannelUpdated {
/// Unique channel identifier.
id: u64, id: u64,
/// Channel name.
name: String, name: String,
/// Whether the channel requires a password.
has_password: bool, has_password: bool,
/// Talk power required to speak; `None` means no restriction.
needed_talk_power: Option<i32>, needed_talk_power: Option<i32>,
}, },
} }
@@ -2058,7 +2098,7 @@ pub fn events_stream(sink: StreamSink<BridgeEvent>) -> Result<(), BridgeError> {
/// Read audio statistics. Errors if no connection or audio not started. /// Read audio statistics. Errors if no connection or audio not started.
pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> { pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
let (s, r, p) = runtime() let (s, r, p, lvl) = runtime()
.spawn(async { session().audio_stats().await }) .spawn(async { session().audio_stats().await })
.await .await
.map_err(|e| task_join_error("audio_stats", e))??; .map_err(|e| task_join_error("audio_stats", e))??;
@@ -2066,9 +2106,42 @@ pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
frames_sent: s, frames_sent: s,
frames_received: r, frames_received: r,
ptt_active: p, ptt_active: p,
input_level: lvl,
}) })
} }
/// Subscribe to real-time microphone input level at ~30 Hz.
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
/// when the Dart subscriber cancels, the session is dropped, or
/// the session becomes persistently unavailable.
pub fn input_level_stream(sink: StreamSink<f32>) -> Result<(), BridgeError> {
runtime().spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(33));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut consecutive_errors = 0u32;
loop {
interval.tick().await;
let level = match session().audio_stats().await {
Ok((_, _, _, lvl)) => {
consecutive_errors = 0;
lvl
}
Err(_) => {
consecutive_errors += 1;
if consecutive_errors >= 10 {
return;
}
-120.0
}
};
if sink.add(level).is_err() {
return;
}
}
});
Ok(())
}
/// Apply the P1 audio-processing config. /// Apply the P1 audio-processing config.
pub async fn set_audio_processing_config( pub async fn set_audio_processing_config(
config: BridgeAudioProcessingConfig, config: BridgeAudioProcessingConfig,
+86 -30
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi, default_rust_auto_opaque = RustAutoOpaqueMoi,
); );
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 281698435; pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -20394775;
// Section: executor // Section: executor
@@ -738,6 +738,42 @@ fn wire__crate__api__init_storage_impl(
}, },
) )
} }
fn wire__crate__api__input_level_stream_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "input_level_stream",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_sink =
<StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec>>::sse_decode(
&mut deserializer,
);
deserializer.end();
move |context| {
transform_result_sse::<_, crate::BridgeError>((move || {
let output_ok = crate::api::input_level_stream(api_sink)?;
Ok(output_ok)
})())
}
},
)
}
fn wire__crate__api__is_connected_impl( fn wire__crate__api__is_connected_impl(
port_: flutter_rust_bridge::for_generated::MessagePort, port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -1790,6 +1826,14 @@ impl SseDecode
} }
} }
impl SseDecode for StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <String>::sse_decode(deserializer);
return StreamSink::deserialize(inner);
}
}
impl SseDecode for String { impl SseDecode for String {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -1962,10 +2006,12 @@ impl SseDecode for crate::api::BridgeAudioStats {
let mut var_framesSent = <u32>::sse_decode(deserializer); let mut var_framesSent = <u32>::sse_decode(deserializer);
let mut var_framesReceived = <u32>::sse_decode(deserializer); let mut var_framesReceived = <u32>::sse_decode(deserializer);
let mut var_pttActive = <bool>::sse_decode(deserializer); let mut var_pttActive = <bool>::sse_decode(deserializer);
let mut var_inputLevel = <f32>::sse_decode(deserializer);
return crate::api::BridgeAudioStats { return crate::api::BridgeAudioStats {
frames_sent: var_framesSent, frames_sent: var_framesSent,
frames_received: var_framesReceived, frames_received: var_framesReceived,
ptt_active: var_pttActive, ptt_active: var_pttActive,
input_level: var_inputLevel,
}; };
} }
} }
@@ -2755,33 +2801,34 @@ fn pde_ffi_dispatcher_primary_impl(
14 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), 14 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len), 15 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), 20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), 21 => wire__crate__api__input_level_stream_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len), 22 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len), 23 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len), 24 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len), 26 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len), 27 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len), 28 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len), 30 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len), 32 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len), 33 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len), 34 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len), 35 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
36 => { 36 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
37 => {
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len) wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
} }
38 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len), 39 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len), 40 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len), 41 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), 42 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len), 43 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), 44 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len), 45 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len), 46 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), 47 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), 48 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len), 49 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
49 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len), 50 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(), _ => unreachable!(),
} }
} }
@@ -2803,10 +2850,10 @@ fn pde_ffi_dispatcher_sync_impl(
data_len, data_len,
), ),
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len), 19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
24 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len), 25 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
28 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len), 29 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len), 31 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), 38 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(), _ => unreachable!(),
} }
} }
@@ -2984,6 +3031,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioStats {
self.frames_sent.into_into_dart().into_dart(), self.frames_sent.into_into_dart().into_dart(),
self.frames_received.into_into_dart().into_dart(), self.frames_received.into_into_dart().into_dart(),
self.ptt_active.into_into_dart().into_dart(), self.ptt_active.into_into_dart().into_dart(),
self.input_level.into_into_dart().into_dart(),
] ]
.into_dart() .into_dart()
} }
@@ -3652,6 +3700,13 @@ impl SseEncode
} }
} }
impl SseEncode for StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
unimplemented!("")
}
}
impl SseEncode for String { impl SseEncode for String {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3781,6 +3836,7 @@ impl SseEncode for crate::api::BridgeAudioStats {
<u32>::sse_encode(self.frames_sent, serializer); <u32>::sse_encode(self.frames_sent, serializer);
<u32>::sse_encode(self.frames_received, serializer); <u32>::sse_encode(self.frames_received, serializer);
<bool>::sse_encode(self.ptt_active, serializer); <bool>::sse_encode(self.ptt_active, serializer);
<f32>::sse_encode(self.input_level, serializer);
} }
} }
+44 -73
View File
@@ -56,6 +56,13 @@ type PendingMoves = HashMap<
), ),
>; >;
struct EventChannels {
voice_in: mpsc::Sender<InboundVoice>,
chat: mpsc::Sender<ChatMessage>,
activity: mpsc::Sender<ServerActivity>,
delta: mpsc::Sender<ProtocolDelta>,
}
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
enum SendTimeoutError<T> { enum SendTimeoutError<T> {
Timeout(T), Timeout(T),
@@ -278,10 +285,12 @@ impl ProtocolClient {
cfg.clone(), cfg.clone(),
rx, rx,
voice_out_rx, voice_out_rx,
voice_in_tx, EventChannels {
chat_tx, voice_in: voice_in_tx,
activity_tx, chat: chat_tx,
delta_tx, activity: activity_tx,
delta: delta_tx,
},
ready_tx, ready_tx,
lost_tx, lost_tx,
)); ));
@@ -471,6 +480,9 @@ impl ProtocolClient {
} }
} }
/// Takes ownership of the delta receiver channel. Returns `None` if already
/// taken. Must be called exactly once during initialization to subscribe to
/// incremental state changes.
pub fn take_delta_rx(&self) -> Option<mpsc::Receiver<ProtocolDelta>> { pub fn take_delta_rx(&self) -> Option<mpsc::Receiver<ProtocolDelta>> {
self.delta_rx.lock().ok().and_then(|mut g| g.take()) self.delta_rx.lock().ok().and_then(|mut g| g.take())
} }
@@ -499,10 +511,7 @@ async fn connection_task(
cfg: ConnectConfig, cfg: ConnectConfig,
mut rx: mpsc::Receiver<Request>, mut rx: mpsc::Receiver<Request>,
mut voice_out_rx: mpsc::Receiver<OutPacket>, mut voice_out_rx: mpsc::Receiver<OutPacket>,
voice_in_tx: mpsc::Sender<InboundVoice>, channels: EventChannels,
chat_tx: mpsc::Sender<ChatMessage>,
activity_tx: mpsc::Sender<ServerActivity>,
delta_tx: mpsc::Sender<ProtocolDelta>,
ready_tx: oneshot::Sender<Result<(), ProtocolError>>, ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
lost_tx: oneshot::Sender<DisconnectReason>, lost_tx: oneshot::Sender<DisconnectReason>,
) { ) {
@@ -683,14 +692,14 @@ async fn connection_task(
Ok(Some(Ok(item))) => { Ok(Some(Ok(item))) => {
match item { match item {
StreamItem::Audio(buf) => { StreamItem::Audio(buf) => {
handle_audio_stream_item(&voice_in_tx, &mut voice_activity, buf).await; handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
} }
other => handle_non_audio_stream_item( other => handle_non_audio_stream_item(
&con, &con,
other, other,
&chat_tx, &channels.chat,
&activity_tx, &channels.activity,
&delta_tx, &channels.delta,
&mut pending_moves, &mut pending_moves,
), ),
} }
@@ -727,13 +736,11 @@ async fn connection_task(
}) })
.collect(); .collect();
for handle in expired { for handle in expired {
if let Some((_target_channel, reply, _)) = pending_moves.remove(&handle) { if let Some((_target_channel, Some(reply), _)) = pending_moves.remove(&handle) {
if let Some(reply) = reply {
let _ = reply.send(Ok(())); let _ = reply.send(Ok(()));
} }
} }
} }
}
// 3. Service at most one control request (non-blocking). // 3. Service at most one control request (non-blocking).
match rx.try_recv() { match rx.try_recv() {
@@ -791,10 +798,7 @@ async fn connection_task(
let r = fetch_client_profile( let r = fetch_client_profile(
&mut con, &mut con,
client_id, client_id,
&voice_in_tx, &channels,
&chat_tx,
&activity_tx,
&delta_tx,
&mut pending_moves, &mut pending_moves,
&mut voice_activity, &mut voice_activity,
) )
@@ -869,7 +873,7 @@ fn handle_non_audio_stream_item(
} = &ev } = &ev
{ {
let own_client = con.get_state().ok().map(|state| state.own_client); let own_client = con.get_state().ok().map(|state| state.own_client);
if let Some(state) = con.get_state().ok() { if let Ok(state) = con.get_state() {
if let Some(client) = state.clients.get(client_id) { if let Some(client) = state.clients.get(client_id) {
let _ = delta_tx.try_send(ProtocolDelta::ClientMoved { let _ = delta_tx.try_send(ProtocolDelta::ClientMoved {
client_id: client_id.0 as u64, client_id: client_id.0 as u64,
@@ -1115,10 +1119,7 @@ fn send_text_to_mode(
async fn fetch_client_profile( async fn fetch_client_profile(
con: &mut Connection, con: &mut Connection,
client_id: u64, client_id: u64,
voice_in_tx: &mpsc::Sender<InboundVoice>, channels: &EventChannels,
chat_tx: &mpsc::Sender<ChatMessage>,
activity_tx: &mpsc::Sender<ServerActivity>,
delta_tx: &mpsc::Sender<ProtocolDelta>,
pending_moves: &mut PendingMoves, pending_moves: &mut PendingMoves,
voice_activity: &mut HashMap<u64, Instant>, voice_activity: &mut HashMap<u64, Instant>,
) -> Result<ClientProfile, ProtocolError> { ) -> Result<ClientProfile, ProtocolError> {
@@ -1155,10 +1156,7 @@ async fn fetch_client_profile(
let _ = request_messages( let _ = request_messages(
con, con,
build_command("servergrouplist", &[], &[]), build_command("servergrouplist", &[], &[]),
voice_in_tx, channels,
chat_tx,
activity_tx,
delta_tx,
pending_moves, pending_moves,
voice_activity, voice_activity,
) )
@@ -1168,10 +1166,7 @@ async fn fetch_client_profile(
let _ = request_messages( let _ = request_messages(
con, con,
build_command("channelgrouplist", &[], &[]), build_command("channelgrouplist", &[], &[]),
voice_in_tx, channels,
chat_tx,
activity_tx,
delta_tx,
pending_moves, pending_moves,
voice_activity, voice_activity,
) )
@@ -1185,10 +1180,7 @@ async fn fetch_client_profile(
&[("clid", client_id.to_string())], &[("clid", client_id.to_string())],
&[], &[],
), ),
voice_in_tx, channels,
chat_tx,
activity_tx,
delta_tx,
pending_moves, pending_moves,
voice_activity, voice_activity,
) )
@@ -1206,10 +1198,7 @@ async fn fetch_client_profile(
if let Err(e) = request_messages( if let Err(e) = request_messages(
con, con,
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]), build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
voice_in_tx, channels,
chat_tx,
activity_tx,
delta_tx,
pending_moves, pending_moves,
voice_activity, voice_activity,
) )
@@ -1228,10 +1217,7 @@ async fn fetch_client_profile(
request_client_db_info( request_client_db_info(
con, con,
database_id, database_id,
voice_in_tx, channels,
chat_tx,
activity_tx,
delta_tx,
pending_moves, pending_moves,
voice_activity, voice_activity,
) )
@@ -1389,10 +1375,7 @@ fn client_profile_refresh_plan(
async fn request_messages( async fn request_messages(
con: &mut Connection, con: &mut Connection,
command: OutCommand, command: OutCommand,
voice_in_tx: &mpsc::Sender<InboundVoice>, channels: &EventChannels,
chat_tx: &mpsc::Sender<ChatMessage>,
activity_tx: &mpsc::Sender<ServerActivity>,
delta_tx: &mpsc::Sender<ProtocolDelta>,
pending_moves: &mut PendingMoves, pending_moves: &mut PendingMoves,
voice_activity: &mut HashMap<u64, Instant>, voice_activity: &mut HashMap<u64, Instant>,
) -> Result<Vec<InMessage>, ProtocolError> { ) -> Result<Vec<InMessage>, ProtocolError> {
@@ -1428,14 +1411,14 @@ async fn request_messages(
return Ok(messages); return Ok(messages);
} }
StreamItem::Audio(buf) => { StreamItem::Audio(buf) => {
handle_audio_stream_item(voice_in_tx, voice_activity, buf).await; handle_audio_stream_item(&channels.voice_in, voice_activity, buf).await;
} }
other => handle_non_audio_stream_item( other => handle_non_audio_stream_item(
con, con,
other, other,
chat_tx, &channels.chat,
activity_tx, &channels.activity,
delta_tx, &channels.delta,
pending_moves, pending_moves,
), ),
} }
@@ -1445,20 +1428,14 @@ async fn request_messages(
async fn request_client_db_info( async fn request_client_db_info(
con: &mut Connection, con: &mut Connection,
dbid: tsclientlib::ClientDbId, dbid: tsclientlib::ClientDbId,
voice_in_tx: &mpsc::Sender<InboundVoice>, channels: &EventChannels,
chat_tx: &mpsc::Sender<ChatMessage>,
activity_tx: &mpsc::Sender<ServerActivity>,
delta_tx: &mpsc::Sender<ProtocolDelta>,
pending_moves: &mut PendingMoves, pending_moves: &mut PendingMoves,
voice_activity: &mut HashMap<u64, Instant>, voice_activity: &mut HashMap<u64, Instant>,
) -> Result<InClientDbInfoPart, ProtocolError> { ) -> Result<InClientDbInfoPart, ProtocolError> {
let messages = request_messages( let messages = request_messages(
con, con,
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]), build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
voice_in_tx, channels,
chat_tx,
activity_tx,
delta_tx,
pending_moves, pending_moves,
voice_activity, voice_activity,
) )
@@ -1781,9 +1758,7 @@ fn format_server_activity(con: &Connection, ev: &tsclientlib::events::Event) ->
extra, extra,
.. ..
} => { } => {
if extra.reason.is_none() { extra.reason?;
return None;
}
let client = activity_client(con, *client_id)?; let client = activity_client(con, *client_id)?;
let channel = activity_channel_name(con, client.channel)?; let channel = activity_channel_name(con, client.channel)?;
Some(format!( Some(format!(
@@ -2161,7 +2136,7 @@ fn forward_delta(
id: PropertyId::Client(client_id), id: PropertyId::Client(client_id),
.. ..
} => { } => {
if let Some(state) = con.get_state().ok() { if let Ok(state) = con.get_state() {
if let Some(client) = state.clients.get(client_id) { if let Some(client) = state.clients.get(client_id) {
let _ = delta_tx.try_send(ProtocolDelta::ClientJoined { let _ = delta_tx.try_send(ProtocolDelta::ClientJoined {
client_id: client_id.0 as u64, client_id: client_id.0 as u64,
@@ -2178,16 +2153,14 @@ fn forward_delta(
} }
Event::PropertyRemoved { Event::PropertyRemoved {
id: PropertyId::Client(_), id: PropertyId::Client(_),
old, old: PropertyValue::Client(client),
.. ..
} => { } => {
if let PropertyValue::Client(client) = old {
let _ = delta_tx.try_send(ProtocolDelta::ClientLeft { let _ = delta_tx.try_send(ProtocolDelta::ClientLeft {
client_id: client.id.0 as u64, client_id: client.id.0 as u64,
name: client.name.clone(), name: client.name.clone(),
}); });
} }
}
Event::PropertyChanged { Event::PropertyChanged {
id: PropertyId::ClientChannel(_), id: PropertyId::ClientChannel(_),
.. ..
@@ -2196,7 +2169,7 @@ fn forward_delta(
id: PropertyId::Client(client_id), id: PropertyId::Client(client_id),
.. ..
} => { } => {
if let Some(state) = con.get_state().ok() { if let Ok(state) = con.get_state() {
if let Some(client) = state.clients.get(client_id) { if let Some(client) = state.clients.get(client_id) {
let _ = delta_tx.try_send(ProtocolDelta::ClientUpdated { let _ = delta_tx.try_send(ProtocolDelta::ClientUpdated {
client_id: client_id.0 as u64, client_id: client_id.0 as u64,
@@ -2213,7 +2186,7 @@ fn forward_delta(
id: PropertyId::Channel(channel_id), id: PropertyId::Channel(channel_id),
.. ..
} => { } => {
if let Some(state) = con.get_state().ok() { if let Ok(state) = con.get_state() {
if let Some(channel) = state.channels.get(channel_id) { if let Some(channel) = state.channels.get(channel_id) {
let _ = delta_tx.try_send(ProtocolDelta::ChannelAdded { let _ = delta_tx.try_send(ProtocolDelta::ChannelAdded {
id: channel_id.0, id: channel_id.0,
@@ -2228,20 +2201,18 @@ fn forward_delta(
} }
Event::PropertyRemoved { Event::PropertyRemoved {
id: PropertyId::Channel(_), id: PropertyId::Channel(_),
old, old: PropertyValue::Channel(channel),
.. ..
} => { } => {
if let PropertyValue::Channel(channel) = old {
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved { let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved {
id: channel.id.0, id: channel.id.0,
}); });
} }
}
Event::PropertyChanged { Event::PropertyChanged {
id: PropertyId::Channel(channel_id), id: PropertyId::Channel(channel_id),
.. ..
} => { } => {
if let Some(state) = con.get_state().ok() { if let Ok(state) = con.get_state() {
if let Some(channel) = state.channels.get(channel_id) { if let Some(channel) = state.channels.get(channel_id) {
let _ = delta_tx.try_send(ProtocolDelta::ChannelUpdated { let _ = delta_tx.try_send(ProtocolDelta::ChannelUpdated {
id: channel_id.0, id: channel_id.0,
+41
View File
@@ -168,52 +168,93 @@ pub struct ServerSnapshot {
} }
impl ChannelId { impl ChannelId {
/// Root/top-level channel identifier.
pub const ROOT: ChannelId = ChannelId(0); pub const ROOT: ChannelId = ChannelId(0);
} }
/// Incremental state change emitted by the protocol adapter when the server tree changes.
///
/// Covers client joins, leaves, moves, and channel additions, removals, and updates.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProtocolDelta { pub enum ProtocolDelta {
/// A client moved to a different channel.
ClientMoved { ClientMoved {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Destination channel identifier.
new_channel_id: u64, new_channel_id: u64,
}, },
/// A new client appeared on the server.
ClientJoined { ClientJoined {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Channel the client joined.
channel_id: u64, channel_id: u64,
/// Display nickname.
name: String, name: String,
/// Microphone muted state.
input_muted: bool, input_muted: bool,
/// Speaker muted state.
output_muted: bool, output_muted: bool,
/// Whether the client is a server query client.
is_server_query: bool, is_server_query: bool,
/// Client's talk power value.
talk_power: i32, talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool, talk_power_granted: bool,
}, },
/// A client disconnected.
ClientLeft { ClientLeft {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Display nickname.
name: String, name: String,
}, },
/// A client's properties changed.
ClientUpdated { ClientUpdated {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Microphone muted state.
input_muted: bool, input_muted: bool,
/// Speaker muted state.
output_muted: bool, output_muted: bool,
/// Whether the client is a server query client.
is_server_query: bool, is_server_query: bool,
/// Client's talk power value.
talk_power: i32, talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool, talk_power_granted: bool,
}, },
/// A new channel appeared.
ChannelAdded { ChannelAdded {
/// Channel identifier.
id: u64, id: u64,
/// Parent channel identifier.
parent: u64, parent: u64,
/// Channel name.
name: String, name: String,
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
order: i64, order: i64,
/// Whether the channel has a password.
has_password: bool, has_password: bool,
/// Needed talk power, or `None` when unrestricted.
needed_talk_power: Option<i32>, needed_talk_power: Option<i32>,
}, },
/// A channel was deleted.
ChannelRemoved { ChannelRemoved {
/// Channel identifier.
id: u64, id: u64,
}, },
/// Channel properties changed.
ChannelUpdated { ChannelUpdated {
/// Channel identifier.
id: u64, id: u64,
/// Channel name.
name: String, name: String,
/// Whether the channel has a password.
has_password: bool, has_password: bool,
/// Needed talk power, or `None` when unrestricted.
needed_talk_power: Option<i32>, needed_talk_power: Option<i32>,
}, },
} }
+6
View File
@@ -628,6 +628,12 @@ fn read_file_dek(path: &Path) -> Result<[u8; 32], StorageError> {
/// headless / sandboxed environments force the file-fallback path /// headless / sandboxed environments force the file-fallback path
/// without poking a real OS keyring (which would either prompt the /// without poking a real OS keyring (which would either prompt the
/// user or block on a missing D-Bus session). /// user or block on a missing D-Bus session).
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
fn keyring_disabled() -> bool { fn keyring_disabled() -> bool {
matches!( matches!(
std::env::var("CHANORA_DISABLE_KEYRING").as_deref(), std::env::var("CHANORA_DISABLE_KEYRING").as_deref(),
+210 -1
View File
@@ -16,7 +16,7 @@ those terms.
| License | Crate count | | License | Crate count |
|---------|-------------| |---------|-------------|
| `Apache License 2.0` | 331 | | `Apache License 2.0` | 333 |
| `MIT License` | 74 | | `MIT License` | 74 |
| `Unicode License v3` | 19 | | `Unicode License v3` | 19 |
| `BSD 3-Clause &quot;New&quot; or &quot;Revised&quot; License` | 14 | | `BSD 3-Clause &quot;New&quot; or &quot;Revised&quot; License` | 14 |
@@ -117,6 +117,7 @@ those terms.
| pin-utils | 0.1.0 | `Apache License 2.0` | <https://github.com/rust-lang-nursery/pin-utils> | | pin-utils | 0.1.0 | `Apache License 2.0` | <https://github.com/rust-lang-nursery/pin-utils> |
| ecdsa | 0.16.9 | `Apache License 2.0` | <https://github.com/RustCrypto/signatures/tree/master/ecdsa> | | ecdsa | 0.16.9 | `Apache License 2.0` | <https://github.com/RustCrypto/signatures/tree/master/ecdsa> |
| rfc6979 | 0.4.0 | `Apache License 2.0` | <https://github.com/RustCrypto/signatures/tree/master/rfc6979> | | rfc6979 | 0.4.0 | `Apache License 2.0` | <https://github.com/RustCrypto/signatures/tree/master/rfc6979> |
| crossbeam | 0.8.4 | `Apache License 2.0` | <https://github.com/crossbeam-rs/crossbeam> |
| ppv-lite86 | 0.2.21 | `Apache License 2.0` | <https://github.com/cryptocorrosion/cryptocorrosion> | | ppv-lite86 | 0.2.21 | `Apache License 2.0` | <https://github.com/cryptocorrosion/cryptocorrosion> |
| rustls-pki-types | 1.14.1 | `Apache License 2.0` | <https://github.com/rustls/pki-types> | | rustls-pki-types | 1.14.1 | `Apache License 2.0` | <https://github.com/rustls/pki-types> |
| keyring | 3.6.3 | `Apache License 2.0` | <https://github.com/hwchen/keyring-rs.git> | | keyring | 3.6.3 | `Apache License 2.0` | <https://github.com/hwchen/keyring-rs.git> |
@@ -144,6 +145,7 @@ those terms.
| critical-section | 1.2.0 | `Apache License 2.0` | <https://github.com/rust-embedded/critical-section> | | critical-section | 1.2.0 | `Apache License 2.0` | <https://github.com/rust-embedded/critical-section> |
| crossbeam-channel | 0.5.15 | `Apache License 2.0` | <https://github.com/crossbeam-rs/crossbeam> | | crossbeam-channel | 0.5.15 | `Apache License 2.0` | <https://github.com/crossbeam-rs/crossbeam> |
| crossbeam-epoch | 0.9.18 | `Apache License 2.0` | <https://github.com/crossbeam-rs/crossbeam> | | crossbeam-epoch | 0.9.18 | `Apache License 2.0` | <https://github.com/crossbeam-rs/crossbeam> |
| crossbeam-queue | 0.3.12 | `Apache License 2.0` | <https://github.com/crossbeam-rs/crossbeam> |
| crossbeam-utils | 0.8.21 | `Apache License 2.0` | <https://github.com/crossbeam-rs/crossbeam> | | crossbeam-utils | 0.8.21 | `Apache License 2.0` | <https://github.com/crossbeam-rs/crossbeam> |
| dbus-secret-service | 4.1.0 | `Apache License 2.0` | <https://github.com/brotskydotcom/dbus-secret-service.git> | | dbus-secret-service | 4.1.0 | `Apache License 2.0` | <https://github.com/brotskydotcom/dbus-secret-service.git> |
| displaydoc | 0.2.6 | `Apache License 2.0` | <https://github.com/yaahc/displaydoc> | | displaydoc | 0.2.6 | `Apache License 2.0` | <https://github.com/yaahc/displaydoc> |
@@ -4993,6 +4995,213 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
END OF TERMS AND CONDITIONS END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets &quot;[]&quot;
replaced with your own identifying information. (Don&#x27;t include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same &quot;printed page&quot; as the copyright notice for easier
identification within third-party archives.
Copyright 2019 The Crossbeam Project Developers
Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an &quot;AS IS&quot; BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
### Apache License 2.0
```
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
&quot;License&quot; shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
&quot;Licensor&quot; shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
&quot;Legal Entity&quot; shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
&quot;control&quot; means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
&quot;You&quot; (or &quot;Your&quot;) shall mean an individual or Legal Entity
exercising permissions granted by this License.
&quot;Source&quot; form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
&quot;Object&quot; form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
&quot;Work&quot; shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
&quot;Derivative Works&quot; shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
&quot;Contribution&quot; shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, &quot;submitted&quot;
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as &quot;Not a Contribution.&quot;
&quot;Contributor&quot; shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a &quot;NOTICE&quot; text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an &quot;AS IS&quot; BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work. APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following To apply the Apache License to your work, attach the following