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
46 changed files with 1115 additions and 407 deletions
+6
View File
@@ -117,6 +117,12 @@ opencode.json
# iOS framework build artifacts produced by chanora_bridge.podspec
/apps/chanora_flutter/ios/Frameworks/
# macOS framework build artifacts produced by chanora_bridge.podspec
# (prepare_command + script_phase rm -rf and regenerate this tree on
# every pod install AND every Xcode build, so tracking it in git is
# pure waste — the committed binary was ~40 MB per commit).
/apps/chanora_flutter/macos/Frameworks/
.opencode/
AGENTS.md
Screenshot 2026-05-17 at 22.23.07.png
Generated
+1 -1
View File
@@ -419,12 +419,12 @@ name = "chanora_audio"
version = "0.2.0-beta.1"
dependencies = [
"audiopus",
"bytemuck",
"chanora_protocol",
"coreaudio-rs",
"cpal",
"criterion",
"crossbeam",
"crossbeam-utils",
"dhat",
"dispatch2",
"futures-util",
@@ -63,8 +63,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
defaultConfig {
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("com.android.application") version "8.13.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
}
include(":app")
-37
View File
@@ -1,69 +1,32 @@
PODS:
- audio_session (0.0.1):
- Flutter
- chanora_bridge (1.0.0)
- connectivity_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_foreground_task (0.0.1):
- Flutter
- haptic_kit (1.0.0):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
DEPENDENCIES:
- audio_session (from `.symlinks/plugins/audio_session/ios`)
- chanora_bridge (from `.`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
- haptic_kit (from `.symlinks/plugins/haptic_kit/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
EXTERNAL SOURCES:
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
chanora_bridge:
:path: "."
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter:
:path: Flutter
flutter_foreground_task:
:path: ".symlinks/plugins/flutter_foreground_task/ios"
haptic_kit:
:path: ".symlinks/plugins/haptic_kit/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS:
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
chanora_bridge: 2ed7c2ba427fab135dd9eab66c507b09cfee113a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
PODFILE CHECKSUM: e2123068539aeb66d53dc1612b383d13f489ede2
@@ -20,6 +20,7 @@
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
FD3C80659716BF7A0C95C7AF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -72,6 +73,7 @@
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>"; };
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 */
/* Begin PBXFrameworksBuildPhase section */
@@ -79,6 +81,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */,
1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */,
);
@@ -128,6 +131,7 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -216,6 +220,7 @@
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
8C5000032DD0000000000001 /* SileroCoreML */,
);
productName = Runner;
@@ -252,6 +257,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
@@ -760,6 +766,10 @@
isa = XCLocalSwiftPackageReference;
relativePath = ../../../silero-coreml;
};
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -768,6 +778,10 @@
package = 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
productName = SileroCoreML;
};
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
@@ -5,6 +5,24 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/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>
<BuildActionEntry
buildForTesting = "YES"
+2 -1
View File
@@ -298,5 +298,6 @@
"clientVolumeMuteAction": "Mute user",
"clientVolumeUnmuteAction": "Unmute user",
"clientVolumeResetAction": "Reset to default",
"permissionDenied": "Permission Denied"
"permissionDenied": "Permission Denied",
"voiceTalkPowerBlocked": "Insufficient talk power to speak in this channel"
}
+2 -1
View File
@@ -241,5 +241,6 @@
"clientVolumeMuteAction": "静音该用户",
"clientVolumeUnmuteAction": "取消静音",
"clientVolumeResetAction": "恢复默认",
"permissionDenied": "权限被拒绝"
"permissionDenied": "权限被拒绝",
"voiceTalkPowerBlocked": "发言权限不足,无法在此频道发言"
}
@@ -1246,6 +1246,12 @@ abstract class AppL10n {
/// In en, this message translates to:
/// **'Permission Denied'**
String get permissionDenied;
/// No description provided for @voiceTalkPowerBlocked.
///
/// In en, this message translates to:
/// **'Insufficient talk power to speak in this channel'**
String get voiceTalkPowerBlocked;
}
class _AppL10nDelegate extends LocalizationsDelegate<AppL10n> {
@@ -653,4 +653,8 @@ class AppL10nEn extends AppL10n {
@override
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
String get permissionDenied => '权限被拒绝';
@override
String get voiceTalkPowerBlocked => '发言权限不足,无法在此频道发言';
}
+107 -61
View File
@@ -312,6 +312,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
rust.BridgeSnapshot? _snapshot;
final List<String> _uiDiagnostics = [];
rust.BridgeAudioStats? _audioStats;
double? _inputLevel;
Timer? _statsTimer;
bool _snapshotRefreshInFlight = false;
bool _snapshotRefreshQueued = false;
@@ -319,6 +320,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
bool _snapshotRefreshQueuedReportErrors = false;
int _connectionEpoch = 0;
StreamSubscription<rust.BridgeEvent>? _eventsSub;
StreamSubscription<double>? _inputLevelSub;
late final PrefetchDebouncer _prefetch;
// v1 voice subsystem state (SDD-094/095/096/097). Driven by
@@ -383,8 +385,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
// 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 MacOSPermissionsService _macOSPermissions = MacOSPermissionsService();
final UiPreferencesService _uiPreferences = const UiPreferencesService();
@override
@@ -395,6 +396,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_prefetch = PrefetchDebouncer(onPrefetch: _onPrefetchServer);
_hostCtl.addListener(_onHostEdited);
_eventsSub = rust.eventsStream().listen(_onEvent);
_inputLevelSub = rust.inputLevelStream().listen((level) {
if (mounted) setState(() => _inputLevel = level);
});
// SDD-106 §5: subscribe to Kotlin -> Dart permissionStateChanged
// events as early as possible so the listen-only banner reflects
// the system state on first frame.
@@ -412,10 +416,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
// capability badge upgrades from L0Focused → L1MacOSEventTap
// when the user grants the permission in System Settings.
_macOSPermissions.start();
_macOSPermissions.checkInitialStates();
_macOSPermissions.pttCapabilityState.addListener(
_onMacOSPttCapabilityChanged,
);
_macOSPermissions.checkInitialStates();
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_requestRecordAudioOnStartup());
});
@@ -530,6 +534,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_pttLevel = level;
if (level == 'L1MacOSEventTap') {
_pttBackendId = 'macos-event-tap';
} else if (level == 'L0Focused') {
_pttBackendId = 'focused';
}
});
}
@@ -670,7 +676,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_reconnectDelay = null;
});
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(() {
_phase = ConnectionPhase.reconnecting;
_reconnectAttempt = attempt;
@@ -845,23 +854,41 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
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) {
_applyClientAdd(rust.BridgeClient(
id: clientId,
channel: channelId,
name: name,
inputMuted: inputMuted,
outputMuted: outputMuted,
isSpeaking: false,
isServerQuery: isServerQuery,
talkPower: talkPower,
talkPowerGranted: talkPowerGranted,
));
_applyClientAdd(
rust.BridgeClient(
id: clientId,
channel: channelId,
name: name,
inputMuted: inputMuted,
outputMuted: outputMuted,
isSpeaking: false,
isServerQuery: isServerQuery,
talkPower: talkPower,
talkPowerGranted: talkPowerGranted,
),
);
}
case rust.BridgeEvent_ClientLeft(:final 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) {
final updated = rust.BridgeClient(
id: c.id,
@@ -876,18 +903,32 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
return updated;
});
case rust.BridgeEvent_ChannelAdded(:final id, :final parent, :final name, :final order, :final hasPassword, :final neededTalkPower):
_applyChannelAdd(rust.BridgeChannel(
id: id,
parent: parent,
name: name,
order: order,
hasPassword: hasPassword,
neededTalkPower: neededTalkPower,
));
case rust.BridgeEvent_ChannelAdded(
:final id,
:final parent,
:final name,
:final order,
:final hasPassword,
:final neededTalkPower,
):
_applyChannelAdd(
rust.BridgeChannel(
id: id,
parent: parent,
name: name,
order: order,
hasPassword: hasPassword,
neededTalkPower: neededTalkPower,
),
);
case rust.BridgeEvent_ChannelRemoved(:final 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) {
return rust.BridgeChannel(
id: ch.id,
@@ -925,7 +966,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final now = DateTime.now();
if (_inChannel &&
(_lastSpeakingRefresh == null ||
now.difference(_lastSpeakingRefresh!) >= _speakingRefreshInterval)) {
now.difference(_lastSpeakingRefresh!) >=
_speakingRefreshInterval)) {
_lastSpeakingRefresh = now;
unawaited(_refreshSnapshot(recordActivity: false, reportErrors: false));
}
@@ -955,6 +997,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
WidgetsBinding.instance.removeObserver(this);
HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey);
_eventsSub?.cancel();
_inputLevelSub?.cancel();
_statsTimer?.cancel();
_snapshotRefreshInFlight = false;
_snapshotRefreshQueued = false;
@@ -1126,14 +1169,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
Future<void> _toggleOutputMute() async {
final next = !_outputMuted;
setState(() {
_outputMuted = next;
});
try {
await rust.setOutputMuted(muted: next);
if (!mounted) return;
setState(() {
_outputMuted = next;
});
} catch (e) {
if (!mounted) return;
setState(() {
_outputMuted = !next;
});
_showUiError('output mute', e);
}
}
@@ -1260,6 +1305,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
return;
}
final next = !_hardMute;
final previousInputMuted = _inputMuted;
final previousHardMute = _hardMute;
final previousPermissionMute = _hardMuteByPermission;
setState(() {
_inputMuted = next;
_hardMute = next;
if (next) {
_hardMuteByPermission = false;
}
});
try {
// Hard-mute is two coordinated effects:
// * setHardMute — local TransmitGate clamp; we stop sending
@@ -1272,14 +1327,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
// through. Drive them together.
await rust.setHardMute(muted: next);
await rust.setInputMuted(muted: next);
if (!mounted) return;
setState(() {
_inputMuted = next;
_hardMute = next;
_hardMuteByPermission = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_inputMuted = previousInputMuted;
_hardMute = previousHardMute;
_hardMuteByPermission = previousPermissionMute;
});
_showUiError('hard mute', e);
}
}
@@ -1737,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;
if (snap == null) return;
final clients = snap.clients.map((c) => test(c) ? update(c) : c).toList();
@@ -1823,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;
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(() {
_snapshot = rust.BridgeSnapshot(
serverName: snap.serverName,
@@ -2145,26 +2207,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final theme = Theme.of(context);
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(
tooltip: l10n.aboutAction,
icon: const Icon(Icons.info_outline),
@@ -2376,6 +2418,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_currentVoiceChannelId,
),
audioStats: _audioStats,
inputLevel: _inputLevel,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
@@ -2457,10 +2500,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute,
outputMuted: _outputMuted,
hardMuteByTalkPower: _hardMuteByTalkPower,
talkPower: ownClientState?.talkPower,
neededTalkPower: ownClientState?.neededTalkPower,
talkPowerGranted: ownClientState?.talkPowerGranted,
onTap: () => _onOpenVoiceDetailsSheet(),
onToggleInputMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
),
if (_inChannel &&
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
@@ -309,12 +309,14 @@ class MacOSPermissionsService {
_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;
}
+16 -2
View File
@@ -261,6 +261,12 @@ Stream<BridgeEvent> eventsStream() =>
Future<BridgeAudioStats> audioStats() =>
RustLib.instance.api.crateApiAudioStats();
/// Subscribe to real-time microphone input level at ~30 Hz.
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
/// when the Dart subscriber cancels or the session is dropped.
Stream<double> inputLevelStream() =>
RustLib.instance.api.crateApiInputLevelStream();
/// Apply the P1 audio-processing config.
Future<void> setAudioProcessingConfig({
required BridgeAudioProcessingConfig config,
@@ -681,15 +687,22 @@ class BridgeAudioStats {
/// Current push-to-talk state.
final bool pttActive;
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
final double inputLevel;
const BridgeAudioStats({
required this.framesSent,
required this.framesReceived,
required this.pttActive,
required this.inputLevel,
});
@override
int get hashCode =>
framesSent.hashCode ^ framesReceived.hashCode ^ pttActive.hashCode;
framesSent.hashCode ^
framesReceived.hashCode ^
pttActive.hashCode ^
inputLevel.hashCode;
@override
bool operator ==(Object other) =>
@@ -698,7 +711,8 @@ class BridgeAudioStats {
runtimeType == other.runtimeType &&
framesSent == other.framesSent &&
framesReceived == other.framesReceived &&
pttActive == other.pttActive;
pttActive == other.pttActive &&
inputLevel == other.inputLevel;
}
/// Bookmark DTO mirroring [`chanora_core::Bookmark`].
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0';
@override
int get rustContentHash => 281698435;
int get rustContentHash => -20394775;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -123,6 +123,8 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiInitStorage({required String dir});
Stream<double> crateApiInputLevelStream();
Future<bool> crateApiIsConnected();
Future<BridgeAudioDeviceList> crateApiListAudioDevices();
@@ -762,6 +764,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiInitStorageConstMeta =>
const TaskConstMeta(debugName: "init_storage", argNames: ["dir"]);
@override
Stream<double> crateApiInputLevelStream() {
final sink = RustStreamSink<double>();
unawaited(
handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_StreamSink_f_32_Sse(sink, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 21,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiInputLevelStreamConstMeta,
argValues: [sink],
apiImpl: this,
),
),
);
return sink.stream;
}
TaskConstMeta get kCrateApiInputLevelStreamConstMeta =>
const TaskConstMeta(debugName: "input_level_stream", argNames: ["sink"]);
@override
Future<bool> crateApiIsConnected() {
return handler.executeNormal(
@@ -771,7 +805,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 21,
funcId: 22,
port: port_,
);
},
@@ -798,7 +832,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 22,
funcId: 23,
port: port_,
);
},
@@ -825,7 +859,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 23,
funcId: 24,
port: port_,
);
},
@@ -849,7 +883,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -879,7 +913,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 25,
funcId: 26,
port: port_,
);
},
@@ -909,7 +943,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 26,
funcId: 27,
port: port_,
);
},
@@ -936,7 +970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 27,
funcId: 28,
port: port_,
);
},
@@ -961,7 +995,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -994,7 +1028,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 29,
funcId: 30,
port: port_,
);
},
@@ -1021,7 +1055,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_audio_route(route, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -1055,7 +1089,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 31,
funcId: 32,
port: port_,
);
},
@@ -1090,7 +1124,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 32,
funcId: 33,
port: port_,
);
},
@@ -1120,7 +1154,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 33,
funcId: 34,
port: port_,
);
},
@@ -1148,7 +1182,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 34,
funcId: 35,
port: port_,
);
},
@@ -1176,7 +1210,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 35,
funcId: 36,
port: port_,
);
},
@@ -1206,7 +1240,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 36,
funcId: 37,
port: port_,
);
},
@@ -1234,7 +1268,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_network_state(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -1260,7 +1294,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 38,
funcId: 39,
port: port_,
);
},
@@ -1288,7 +1322,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 39,
funcId: 40,
port: port_,
);
},
@@ -1316,7 +1350,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 40,
funcId: 41,
port: port_,
);
},
@@ -1344,7 +1378,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 41,
funcId: 42,
port: port_,
);
},
@@ -1376,7 +1410,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 42,
funcId: 43,
port: port_,
);
},
@@ -1406,7 +1440,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 43,
funcId: 44,
port: port_,
);
},
@@ -1434,7 +1468,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 44,
funcId: 45,
port: port_,
);
},
@@ -1462,7 +1496,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 45,
funcId: 46,
port: port_,
);
},
@@ -1489,7 +1523,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 46,
funcId: 47,
port: port_,
);
},
@@ -1517,7 +1551,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 47,
funcId: 48,
port: port_,
);
},
@@ -1549,7 +1583,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 48,
funcId: 49,
port: port_,
);
},
@@ -1578,7 +1612,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 49,
funcId: 50,
port: port_,
);
},
@@ -1610,6 +1644,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
throw UnimplementedError();
}
@protected
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
throw UnimplementedError();
}
@protected
String dco_decode_String(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -1781,12 +1821,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 3)
throw Exception('unexpected arr length: expect 3 but see ${arr.length}');
if (arr.length != 4)
throw Exception('unexpected arr length: expect 4 but see ${arr.length}');
return BridgeAudioStats(
framesSent: dco_decode_u_32(arr[0]),
framesReceived: dco_decode_u_32(arr[1]),
pttActive: dco_decode_bool(arr[2]),
inputLevel: dco_decode_f_32(arr[3]),
);
}
@@ -2271,6 +2312,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
throw UnimplementedError('Unreachable ()');
}
@protected
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
throw UnimplementedError('Unreachable ()');
}
@protected
String sse_decode_String(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -2488,10 +2537,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_framesSent = sse_decode_u_32(deserializer);
var var_framesReceived = sse_decode_u_32(deserializer);
var var_pttActive = sse_decode_bool(deserializer);
var var_inputLevel = sse_decode_f_32(deserializer);
return BridgeAudioStats(
framesSent: var_framesSent,
framesReceived: var_framesReceived,
pttActive: var_pttActive,
inputLevel: var_inputLevel,
);
}
@@ -3199,6 +3250,23 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
@protected
void sse_encode_StreamSink_f_32_Sse(
RustStreamSink<double> self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_String(
self.setupAndSerialize(
codec: SseCodec(
decodeSuccessData: sse_decode_f_32,
decodeErrorData: sse_decode_AnyhowException,
),
),
serializer,
);
}
@protected
void sse_encode_String(String self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -3380,6 +3448,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_u_32(self.framesSent, serializer);
sse_encode_u_32(self.framesReceived, serializer);
sse_encode_bool(self.pttActive, serializer);
sse_encode_f_32(self.inputLevel, serializer);
}
@protected
@@ -27,6 +27,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw);
@protected
String dco_decode_String(dynamic raw);
@@ -210,6 +213,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
SseDeserializer deserializer,
);
@protected
String sse_decode_String(SseDeserializer deserializer);
@@ -439,6 +447,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_StreamSink_f_32_Sse(
RustStreamSink<double> self,
SseSerializer serializer,
);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@@ -29,6 +29,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw);
@protected
String dco_decode_String(dynamic raw);
@@ -212,6 +215,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
SseDeserializer deserializer,
);
@protected
String sse_decode_String(SseDeserializer deserializer);
@@ -441,6 +449,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_StreamSink_f_32_Sse(
RustStreamSink<double> self,
SseSerializer serializer,
);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@@ -33,6 +33,7 @@ class VoiceBar extends StatelessWidget {
required this.onConfigure,
required this.onPttHeldChanged,
this.talkPowerBlocked = false,
this.inputLevel,
});
final bool inChannel;
@@ -53,6 +54,10 @@ class VoiceBar extends StatelessWidget {
/// level meter. Pass `null` to render an idle meter.
final rust.BridgeAudioStats? audioStats;
/// Real-time input level from the 30 Hz stream (dBFS).
/// When non-null, takes precedence over `audioStats.inputLevel`.
final double? inputLevel;
/// PTT capability badge inputs — passed through to
/// [`PttCapabilityBadge`].
final String pttLevel;
@@ -196,7 +201,7 @@ class VoiceBar extends StatelessWidget {
),
const SizedBox(height: 6),
// Row 4: level meter
VoiceLevelMeter(active: levelActive),
VoiceLevelMeter(active: levelActive, level: inputLevel ?? stats?.inputLevel),
const SizedBox(height: 4),
if (stats != null)
Text(
@@ -7,7 +7,7 @@
// release-tail are surfaced inline (radio buttons + slider) inside
// the modal.
import 'dart:async' show Timer, unawaited;
import 'dart:async' show StreamSubscription, Timer, unawaited;
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
@@ -53,8 +53,11 @@ class VoiceStatusChip extends StatelessWidget {
required this.audioStats,
required this.isTouchOnly,
required this.onTap,
required this.onToggleInputMute,
required this.onToggleOutputMute,
this.inputMuted = false,
this.outputMuted = false,
this.hardMuteByTalkPower = false,
this.talkPower,
this.neededTalkPower,
this.talkPowerGranted,
@@ -81,6 +84,9 @@ class VoiceStatusChip extends StatelessWidget {
/// True when local speaker is muted.
final bool outputMuted;
/// True when the server talk-power gate forces local hard mute.
final bool hardMuteByTalkPower;
/// Own client's talk power.
final int? talkPower;
@@ -93,6 +99,12 @@ class VoiceStatusChip extends StatelessWidget {
/// Open the voice details modal.
final VoidCallback onTap;
/// Toggle local input hard mute.
final VoidCallback onToggleInputMute;
/// Toggle local output mute/deafen.
final VoidCallback onToggleOutputMute;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -113,49 +125,48 @@ class VoiceStatusChip extends StatelessWidget {
);
return Semantics(
button: true,
label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}',
hint: l10n.voiceSettingsTitle,
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onTap();
},
borderRadius: BorderRadius.circular(12),
child: ExcludeSemantics(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: summary.talkPowerBlocked
? Colors.amber.withValues(alpha: 0.18)
: summary.muted
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: summary.talkPowerBlocked
? Colors.amber.shade700
: summary.muted
? theme.colorScheme.error
: theme.colorScheme.outlineVariant,
width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5,
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: summary.talkPowerBlocked
? Colors.amber.withValues(alpha: 0.18)
: summary.muted
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: summary.talkPowerBlocked
? Colors.amber.shade700
: summary.muted
? theme.colorScheme.error
: theme.colorScheme.outlineVariant,
width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5,
),
),
child: Row(
children: [
Icon(
summary.micOn
? Icons.fiber_manual_record
: Icons.fiber_manual_record_outlined,
size: 12,
color: summary.micOn
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
child: Row(
children: [
Icon(
summary.micOn
? Icons.fiber_manual_record
: Icons.fiber_manual_record_outlined,
size: 12,
color: summary.micOn
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
const SizedBox(width: 8),
Expanded(
const SizedBox(width: 8),
Expanded(
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onTap();
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
@@ -179,15 +190,60 @@ class VoiceStatusChip extends StatelessWidget {
],
),
),
const SizedBox(width: 8),
Icon(
Icons.expand_less,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
),
const SizedBox(width: 4),
IconButton(
tooltip: hardMuteByTalkPower
? l10n.voiceTalkPowerBlocked
: l10n.voiceHardMuteLabel,
icon: Icon(inputMuted ? Icons.mic_off : Icons.mic),
color: inputMuted ? theme.colorScheme.error : null,
onPressed: hardMuteByTalkPower ? null : onToggleInputMute,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 40,
height: 40,
),
padding: EdgeInsets.zero,
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
IconButton(
tooltip: l10n.voiceOutputMuteLabel,
icon: Icon(outputMuted ? Icons.headset_off : Icons.headset),
color: outputMuted ? theme.colorScheme.error : null,
onPressed: onToggleOutputMute,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 40,
height: 40,
),
padding: EdgeInsets.zero,
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
IconButton(
tooltip: l10n.voiceSettingsTitle,
icon: Icon(
Icons.expand_less,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
onPressed: onTap,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 40,
height: 40,
),
padding: EdgeInsets.zero,
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
),
),
@@ -455,6 +511,8 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
int _rateTickCount = 0;
late final AudioProcessingConfigState _audioProcessing;
double? _streamLevel;
StreamSubscription<double>? _levelSub;
@override
void initState() {
@@ -463,8 +521,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
widget.initialAudioConfig,
);
// Poll audio stats at 250 ms so TX/RX counters and the level meter
// update in real time while the sheet is open, independent of the parent.
_levelSub = rust.inputLevelStream().listen((level) {
if (mounted) setState(() => _streamLevel = level);
});
// Poll audio stats at 250 ms so TX/RX counters update in real time
// while the sheet is open.
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
try {
final s = await rust.audioStats();
@@ -472,7 +534,6 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
setState(() {
_stats = s;
_rateTickCount++;
// Compute rates every ~1 s (4 × 250 ms).
if (_rateTickCount >= 4) {
_txRate = s.framesSent - _prevSent;
_rxRate = s.framesReceived - _prevReceived;
@@ -487,6 +548,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
@override
void dispose() {
_levelSub?.cancel();
_statsTimer?.cancel();
super.dispose();
}
@@ -625,7 +687,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
const SizedBox(height: 12),
// 4) Level meter + live TX/RX stats.
VoiceLevelMeter(active: levelActive),
VoiceLevelMeter(active: levelActive, level: _streamLevel ?? stats?.inputLevel),
const SizedBox(height: 6),
_StatsRow(
txRate: _txRate,
@@ -1,28 +1,78 @@
import 'dart:math' show max;
import 'package:flutter/material.dart';
/// Shared compact level meter used by voice surfaces.
class VoiceLevelMeter extends StatelessWidget {
const VoiceLevelMeter({super.key, required this.active});
///
/// When [level] is null (no stats available yet), falls back to [active]
/// for a binary indicator. When [level] is provided it is interpreted as
/// dBFS and mapped to a 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;
/// Real input level in dBFS (-120 = silence, 0 = clipping).
/// Null means stats are not yet available; [active] is used instead.
final double? level;
/// Map dBFS [-60, 0] → [0.0, 1.0].
static double dbfsToFraction(double dbfs) {
const floor = -60.0;
if (dbfs <= floor) return 0.0;
if (dbfs >= 0.0) return 1.0;
return (dbfs - floor) / -floor;
}
@override
State<VoiceLevelMeter> createState() => _VoiceLevelMeterState();
}
class _VoiceLevelMeterState extends State<VoiceLevelMeter> {
double _previousFill = 0.0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final double fill;
final Color color;
if (widget.level != null) {
fill = VoiceLevelMeter.dbfsToFraction(widget.level!);
color = fill > 0.0
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant;
} else {
fill = widget.active ? 0.75 : 0.05;
color = widget.active
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant;
}
final begin = _previousFill;
_previousFill = fill;
return Container(
height: 8,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: FractionallySizedBox(
alignment: AlignmentDirectional.centerStart,
widthFactor: active ? 0.75 : 0.05,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: begin, end: fill),
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
builder: (context, animatedFill, child) {
return FractionallySizedBox(
alignment: AlignmentDirectional.centerStart,
widthFactor: max(animatedFill, 0.02),
child: child,
);
},
child: Container(
decoration: BoxDecoration(
color: active
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
color: color,
borderRadius: BorderRadius.circular(4),
),
),
@@ -1 +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:
- audio_session (0.0.1):
- FlutterMacOS
- chanora_bridge (1.0.0)
- connectivity_plus (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- package_info_plus (0.0.1):
- FlutterMacOS
- share_plus (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
DEPENDENCIES:
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
- chanora_bridge (from `/Users/edison/dev/chanora/apps/chanora_flutter/macos`)
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
EXTERNAL SOURCES:
audio_session:
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
chanora_bridge:
:path: "/Users/edison/dev/chanora/apps/chanora_flutter/macos"
connectivity_plus:
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
FlutterMacOS:
:path: Flutter/ephemeral
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
share_plus:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS:
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
chanora_bridge: 4105993843b5421ee4ce72220a74c63f6fd99103
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb
@@ -32,6 +32,7 @@
45F255D1DE0134185DB5423D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 06E1AA7E1FB968C1D78DA8DE /* PrivacyInfo.xcprivacy */; };
9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */; };
C2DC22E19FDCE26B9D79442E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -93,6 +94,7 @@
ED6F5EE0C5FD4DA22D79188C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -108,6 +110,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
8C6000042DD0000000000001 /* SileroCoreML in Frameworks */,
9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */,
);
@@ -170,6 +173,7 @@
33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
@@ -256,6 +260,7 @@
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
8C6000032DD0000000000001 /* SileroCoreML */,
);
productName = Runner;
@@ -302,6 +307,7 @@
);
mainGroup = 33CC10E42044A3C60003C045;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
);
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
@@ -865,6 +871,10 @@
isa = XCLocalSwiftPackageReference;
relativePath = ../../../silero-coreml;
};
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -873,6 +883,10 @@
package = 8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
productName = SileroCoreML;
};
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
@@ -5,6 +5,24 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "&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>
<BuildActionEntry
buildForTesting = "YES"
@@ -48,6 +48,7 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
// -- Input Monitoring ---------------------------------------------------
case "checkInputMonitoring":
result(inputMonitoringStateString())
startInputMonitoringPolling()
case "requestInputMonitoring":
requestInputMonitoring(result: result)
+4 -4
View File
@@ -457,10 +457,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: transitive
description:
@@ -790,10 +790,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
typed_data:
dependency: transitive
description:
@@ -493,6 +493,7 @@ void main() {
framesSent: 1,
framesReceived: 0,
pttActive: true,
inputLevel: -30.0,
),
),
);
+42 -2
View File
@@ -253,49 +253,87 @@ pub enum SessionEvent {
},
/// Audio route changed (speaker/earpiece/BT/wired headset).
AudioRouteChanged {
/// New audio output route.
route: AudioRoute,
},
/// A client moved to a different channel.
ClientMoved {
/// Unique client identifier.
client_id: u64,
/// Destination channel.
new_channel_id: u64,
},
/// A new client connected.
ClientJoined {
/// Unique client identifier.
client_id: u64,
/// Channel the client joined.
channel_id: u64,
/// Display nickname.
name: String,
/// Microphone muted state.
input_muted: bool,
/// Speaker muted state.
output_muted: bool,
/// Whether this is a server query (bot) client.
is_server_query: bool,
/// Client's talk power value.
talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool,
},
/// A client disconnected.
ClientLeft {
/// Unique client identifier.
client_id: u64,
/// Display nickname at time of disconnect.
name: String,
},
/// Client properties changed.
ClientUpdated {
/// Unique client identifier.
client_id: u64,
/// Microphone muted state.
input_muted: bool,
/// Speaker muted state.
output_muted: bool,
/// Whether this is a server query (bot) client.
is_server_query: bool,
/// Client's talk power value.
talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool,
},
/// A new channel appeared.
ChannelAdded {
/// Unique channel identifier.
id: u64,
/// Parent channel ID.
parent: u64,
/// Channel name.
name: String,
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
order: i64,
/// Whether the channel requires a password.
has_password: bool,
/// Talk power required to speak, or `None` when unrestricted.
needed_talk_power: Option<i32>,
},
/// A channel was deleted.
ChannelRemoved {
/// Channel identifier.
id: u64,
},
/// Channel properties changed.
ChannelUpdated {
/// Unique channel identifier.
id: u64,
/// Channel name.
name: String,
/// Whether the channel requires a password.
has_password: bool,
/// Talk power required to speak, or `None` when unrestricted.
needed_talk_power: Option<i32>,
},
}
@@ -1307,8 +1345,8 @@ impl ChanoraSession {
Ok(())
}
/// Read audio engine statistics: (frames_sent, frames_received, transmit_active).
pub async fn audio_stats(&self) -> Result<(u32, u32, bool), CoreError> {
/// Read audio engine statistics: (frames_sent, frames_received, transmit_active, input_level_dbfs).
pub async fn audio_stats(&self) -> Result<(u32, u32, bool, f32), CoreError> {
let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
@@ -1316,6 +1354,7 @@ impl ChanoraSession {
audio.frames_sent(),
audio.frames_received(),
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
/// memberships, names, and orderings produce the same signature;
/// any in-channel move, rename, or reorder produces a different one.
#[cfg(test)]
fn snapshot_signature(snap: &ServerSnapshot) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
+5 -1
View File
@@ -30,7 +30,6 @@ tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
rustfft = "6.2.0"
crossbeam = { version = "0.8", default-features = false, features = ["alloc", "crossbeam-queue"] }
crossbeam-utils = { version = "0.8", default-features = false }
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
# Desktop audio I/O for Windows capture/playback and Linux capture.
@@ -74,6 +73,11 @@ ndk-context = "0.1"
# - deduplicated macro impls
# - PowerSavingOffloaded PerformanceMode variant
oboe = { git = "https://github.com/EdisonJwa/oboe-rs", rev = "a14f9b83ecea8c93f5a692f2ee7808445b938c35" }
# Safe slice reinterpret for the oboe stereo output callback.
# bytemuck::cast_slice_mut replaces the raw-pointer cast from
# `&mut [(f32, f32)]` to `&mut [f32]` with a provenance-correct
# and UB-free transmute backed by `NoUninit`.
bytemuck = { version = "1", features = ["derive"] }
[target.'cfg(target_os = "windows")'.dependencies]
# Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices
+9 -11
View File
@@ -523,7 +523,7 @@ impl AudioInputCallback for InputCallback {
struct OutputCallback {
handler: AudioHandler<SessionAudioId>,
event_queue: Arc<AudioEventQueue>,
event_consumer: crate::audio_event_queue::AudioEventConsumer,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
event_tx: BackendEventTx,
@@ -542,14 +542,12 @@ impl AudioOutputCallback for OutputCallback {
frames: &mut [(f32, f32)],
) -> DataCallbackResult {
let _ = catch_unwind(AssertUnwindSafe(|| {
let buf: &mut [f32] = unsafe {
std::slice::from_raw_parts_mut(frames.as_mut_ptr() as *mut f32, frames.len() * 2)
};
let buf: &mut [f32] =
bytemuck::cast_slice_mut::<(f32, f32), f32>(frames);
for s in buf.iter_mut() {
*s = 0.0;
}
let consumer = AudioEventQueue::consumer(&self.event_queue);
for cmd in consumer.drain_controls() {
for cmd in self.event_consumer.drain_controls() {
match cmd {
AudioCommand::SetVolume(id, vol) => {
if let Some(q) = self.handler.get_mut_queues().get_mut(&id) {
@@ -562,7 +560,7 @@ impl AudioOutputCallback for OutputCallback {
}
}
for pkt in consumer.drain_packets(50) {
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");
}
@@ -793,7 +791,7 @@ impl AndroidVoiceUnit {
let event_queue = params.event_producer.queue();
let output_cb = OutputCallback {
handler: params.handler,
event_queue: event_queue.clone(),
event_consumer: AudioEventQueue::consumer(&event_queue),
output_gain: params.output_gain.clone(),
output_muted: params.output_muted.clone(),
event_tx: event_tx.clone(),
@@ -816,7 +814,7 @@ impl AndroidVoiceUnit {
cfg,
&event_tx,
AudioHandler::new(),
event_queue.clone(),
AudioEventQueue::consumer(&event_queue),
params.output_gain.clone(),
params.output_muted.clone(),
audio_processing_stats.clone(),
@@ -1069,7 +1067,7 @@ impl AndroidVoiceUnit {
cfg: &AndroidVoiceStreamConfig,
event_tx: &BackendEventTx,
handler: AudioHandler<SessionAudioId>,
event_queue: Arc<AudioEventQueue>,
event_consumer: crate::audio_event_queue::AudioEventConsumer,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
@@ -1077,7 +1075,7 @@ impl AndroidVoiceUnit {
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
let cb = OutputCallback {
handler,
event_queue,
event_consumer,
output_gain,
output_muted,
event_tx: event_tx.clone(),
@@ -22,6 +22,8 @@ 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),
}
@@ -404,6 +404,19 @@ impl Default for SharedAudioProcessingStats {
}
impl SharedAudioProcessingStats {
/// Store the raw input dBFS level (desktop capture path).
/// Mobile platforms use [`Self::update_capture`] instead, which
/// also records VAD state; this lighter method is for the cpal
/// capture path that has no VAD pipeline.
pub fn set_input_dbfs(&self, dbfs: f32) {
self.input_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
}
/// Read the current input dBFS level.
pub fn input_dbfs(&self) -> f32 {
f32::from_bits(self.input_dbfs.load(Ordering::Relaxed))
}
/// Store capture levels and VAD state.
pub fn update_capture(
&self,
+39 -19
View File
@@ -838,6 +838,7 @@ impl AudioEngine {
transmit_flag_for_capture,
frames_sent.clone(),
cfg.mic_gain,
audio_processing_stats.clone(),
);
let (input_stream, capture_active) = match capture_result {
Ok(s) => (Some(s), true),
@@ -1597,6 +1598,11 @@ impl AudioEngine {
self.frames_received.load(Ordering::Relaxed)
}
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
pub fn input_level(&self) -> f32 {
self.audio_processing_stats.input_dbfs()
}
/// Current audio-processing config snapshot.
pub fn audio_processing_config_snapshot(&self) -> crate::AudioProcessingConfig {
self.audio_processing_config.lock().unwrap().clone()
@@ -1664,15 +1670,21 @@ impl AudioEngine {
#[cfg(target_os = "android")]
{
let mut cmd = AudioCommand::SetVolume(SessionAudioId(client_id), clamped);
loop {
for _ in 0..64 {
match self.audio_event_producer.push_control(cmd) {
Ok(()) => break,
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"))]
{
@@ -1711,6 +1723,7 @@ fn try_open_capture(
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Result<cpal::Stream, AudioError> {
let in_cfg = in_dev
.default_input_config()
@@ -1746,6 +1759,7 @@ fn try_open_capture(
voice_out_tx,
transmit_active,
frames_sent,
audio_processing_stats,
)));
let stream = match in_format {
@@ -1797,6 +1811,7 @@ struct CaptureState {
/// capacity so the drain-into-frame path skips the allocator
/// after warmup. Same precedent as `mono_scratch` above.
frame_scratch: Vec<f32>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
@@ -1809,6 +1824,7 @@ impl CaptureState {
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Self {
Self {
encoder,
@@ -1822,12 +1838,9 @@ impl CaptureState {
voice_out_tx,
transmit_active,
frames_sent,
// Generous upper bound for typical cpal periods
// (commonly 256..1024 frames); `clear()` retains the
// backing allocation across callbacks. See struct doc.
mono_scratch: Vec::with_capacity(4096),
// Exact upper bound: drain pulls FRAME_SAMPLES at a time.
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
audio_processing_stats,
}
}
@@ -1835,17 +1848,8 @@ impl CaptureState {
/// 48 kHz mono frames; encode and send when `transmit_active`
/// is true (PTT engaged).
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
if !self.transmit_active.load(Ordering::Relaxed) {
// Drain accumulator while muted so we don't pop on PTT release.
self.pcm_accum.clear();
return;
}
// 1. Down-mix to mono + gain.
// Reuse `self.mono_scratch` to avoid a per-callback Vec
// allocation on the realtime audio thread; see struct
// doc and the engine.rs:1389-1397 precedent for why this
// matters for user-perceptible audio popping.
// 1. Down-mix to mono (pre-gain). Always performed so the level
// meter reflects real mic input even when PTT is released.
let in_channels = self.in_channels;
let mic_gain = self.mic_gain;
self.mono_scratch.clear();
@@ -1853,8 +1857,23 @@ impl CaptureState {
self.mono_scratch.reserve(frame_count);
for frame in buf.chunks(in_channels) {
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
self.mono_scratch
.push((sum / frame.len() as f32) * mic_gain);
self.mono_scratch.push(sum / frame.len() as f32);
}
// 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
@@ -2482,6 +2501,7 @@ pub mod bench_seam {
tx,
transmit_active.clone(),
frames_sent,
Arc::new(crate::SharedAudioProcessingStats::default()),
);
Self {
state,
@@ -22,9 +22,6 @@ use std::sync::atomic::{AtomicBool, AtomicU32};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
#[cfg(not(target_os = "android"))]
use tsclientlib::audio::AudioHandler;
#[cfg(target_os = "android")]
use tsclientlib::audio::AudioHandler;
use crate::engine::SessionAudioId;
+74 -1
View File
@@ -1038,6 +1038,8 @@ pub struct BridgeAudioStats {
pub frames_received: u32,
/// Current push-to-talk state.
pub ptt_active: bool,
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
pub input_level: f32,
}
/// Bridge route class for P1 audio-processing policy.
@@ -1701,49 +1703,87 @@ pub enum BridgeEvent {
},
/// Audio route changed (speaker/earpiece/BT/wired).
AudioRouteChanged {
/// New audio output route.
route: BridgeAudioRoute,
},
/// A client moved to a different channel.
ClientMoved {
/// Unique client identifier.
client_id: u64,
/// Destination channel.
new_channel_id: u64,
},
/// A new client connected.
ClientJoined {
/// Unique client identifier.
client_id: u64,
/// Channel the client joined.
channel_id: u64,
/// Display nickname.
name: String,
/// Microphone muted state.
input_muted: bool,
/// Speaker muted state.
output_muted: bool,
/// True for server query (bot) clients.
is_server_query: bool,
/// Client's talk power value.
talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool,
},
/// A client disconnected.
ClientLeft {
/// Unique client identifier.
client_id: u64,
/// Display nickname at time of disconnect.
name: String,
},
/// Client properties changed.
ClientUpdated {
/// Unique client identifier.
client_id: u64,
/// Microphone muted state.
input_muted: bool,
/// Speaker muted state.
output_muted: bool,
/// True for server query (bot) clients.
is_server_query: bool,
/// Client's talk power value.
talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool,
},
/// A new channel appeared.
ChannelAdded {
/// Unique channel identifier.
id: u64,
/// Parent channel ID.
parent: u64,
/// Channel name.
name: String,
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
order: i64,
/// Whether the channel requires a password.
has_password: bool,
/// Talk power required to speak; `None` means no restriction.
needed_talk_power: Option<i32>,
},
/// A channel was deleted.
ChannelRemoved {
/// Channel identifier.
id: u64,
},
/// Channel properties changed.
ChannelUpdated {
/// Unique channel identifier.
id: u64,
/// Channel name.
name: String,
/// Whether the channel requires a password.
has_password: bool,
/// Talk power required to speak; `None` means no restriction.
needed_talk_power: Option<i32>,
},
}
@@ -2058,7 +2098,7 @@ pub fn events_stream(sink: StreamSink<BridgeEvent>) -> Result<(), BridgeError> {
/// Read audio statistics. Errors if no connection or audio not started.
pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
let (s, r, p) = runtime()
let (s, r, p, lvl) = runtime()
.spawn(async { session().audio_stats().await })
.await
.map_err(|e| task_join_error("audio_stats", e))??;
@@ -2066,9 +2106,42 @@ pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
frames_sent: s,
frames_received: r,
ptt_active: p,
input_level: lvl,
})
}
/// Subscribe to real-time microphone input level at ~30 Hz.
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
/// when the Dart subscriber cancels, the session is dropped, or
/// the session becomes persistently unavailable.
pub fn input_level_stream(sink: StreamSink<f32>) -> Result<(), BridgeError> {
runtime().spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(33));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut consecutive_errors = 0u32;
loop {
interval.tick().await;
let level = match session().audio_stats().await {
Ok((_, _, _, lvl)) => {
consecutive_errors = 0;
lvl
}
Err(_) => {
consecutive_errors += 1;
if consecutive_errors >= 10 {
return;
}
-120.0
}
};
if sink.add(level).is_err() {
return;
}
}
});
Ok(())
}
/// Apply the P1 audio-processing config.
pub async fn set_audio_processing_config(
config: BridgeAudioProcessingConfig,
+86 -30
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 281698435;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -20394775;
// Section: executor
@@ -738,6 +738,42 @@ fn wire__crate__api__init_storage_impl(
},
)
}
fn wire__crate__api__input_level_stream_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "input_level_stream",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_sink =
<StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec>>::sse_decode(
&mut deserializer,
);
deserializer.end();
move |context| {
transform_result_sse::<_, crate::BridgeError>((move || {
let output_ok = crate::api::input_level_stream(api_sink)?;
Ok(output_ok)
})())
}
},
)
}
fn wire__crate__api__is_connected_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -1790,6 +1826,14 @@ impl SseDecode
}
}
impl SseDecode for StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <String>::sse_decode(deserializer);
return StreamSink::deserialize(inner);
}
}
impl SseDecode for String {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -1962,10 +2006,12 @@ impl SseDecode for crate::api::BridgeAudioStats {
let mut var_framesSent = <u32>::sse_decode(deserializer);
let mut var_framesReceived = <u32>::sse_decode(deserializer);
let mut var_pttActive = <bool>::sse_decode(deserializer);
let mut var_inputLevel = <f32>::sse_decode(deserializer);
return crate::api::BridgeAudioStats {
frames_sent: var_framesSent,
frames_received: var_framesReceived,
ptt_active: var_pttActive,
input_level: var_inputLevel,
};
}
}
@@ -2755,33 +2801,34 @@ fn pde_ffi_dispatcher_primary_impl(
14 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
36 => {
21 => wire__crate__api__input_level_stream_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
36 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
37 => {
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
}
38 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
49 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
49 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
50 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2803,10 +2850,10 @@ fn pde_ffi_dispatcher_sync_impl(
data_len,
),
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
24 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
28 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
25 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
29 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
38 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2984,6 +3031,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioStats {
self.frames_sent.into_into_dart().into_dart(),
self.frames_received.into_into_dart().into_dart(),
self.ptt_active.into_into_dart().into_dart(),
self.input_level.into_into_dart().into_dart(),
]
.into_dart()
}
@@ -3652,6 +3700,13 @@ impl SseEncode
}
}
impl SseEncode for StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
unimplemented!("")
}
}
impl SseEncode for String {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3781,6 +3836,7 @@ impl SseEncode for crate::api::BridgeAudioStats {
<u32>::sse_encode(self.frames_sent, serializer);
<u32>::sse_encode(self.frames_received, serializer);
<bool>::sse_encode(self.ptt_active, serializer);
<f32>::sse_encode(self.input_level, serializer);
}
}
+52 -81
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)]
enum SendTimeoutError<T> {
Timeout(T),
@@ -278,10 +285,12 @@ impl ProtocolClient {
cfg.clone(),
rx,
voice_out_rx,
voice_in_tx,
chat_tx,
activity_tx,
delta_tx,
EventChannels {
voice_in: voice_in_tx,
chat: chat_tx,
activity: activity_tx,
delta: delta_tx,
},
ready_tx,
lost_tx,
));
@@ -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>> {
self.delta_rx.lock().ok().and_then(|mut g| g.take())
}
@@ -499,10 +511,7 @@ async fn connection_task(
cfg: ConnectConfig,
mut rx: mpsc::Receiver<Request>,
mut voice_out_rx: mpsc::Receiver<OutPacket>,
voice_in_tx: mpsc::Sender<InboundVoice>,
chat_tx: mpsc::Sender<ChatMessage>,
activity_tx: mpsc::Sender<ServerActivity>,
delta_tx: mpsc::Sender<ProtocolDelta>,
channels: EventChannels,
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
lost_tx: oneshot::Sender<DisconnectReason>,
) {
@@ -683,14 +692,14 @@ async fn connection_task(
Ok(Some(Ok(item))) => {
match item {
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(
&con,
other,
&chat_tx,
&activity_tx,
&delta_tx,
&channels.chat,
&channels.activity,
&channels.delta,
&mut pending_moves,
),
}
@@ -727,10 +736,8 @@ async fn connection_task(
})
.collect();
for handle in expired {
if let Some((_target_channel, reply, _)) = pending_moves.remove(&handle) {
if let Some(reply) = reply {
let _ = reply.send(Ok(()));
}
if let Some((_target_channel, Some(reply), _)) = pending_moves.remove(&handle) {
let _ = reply.send(Ok(()));
}
}
}
@@ -791,10 +798,7 @@ async fn connection_task(
let r = fetch_client_profile(
&mut con,
client_id,
&voice_in_tx,
&chat_tx,
&activity_tx,
&delta_tx,
&channels,
&mut pending_moves,
&mut voice_activity,
)
@@ -869,7 +873,7 @@ fn handle_non_audio_stream_item(
} = &ev
{
let own_client = con.get_state().ok().map(|state| state.own_client);
if let Some(state) = con.get_state().ok() {
if let Ok(state) = con.get_state() {
if let Some(client) = state.clients.get(client_id) {
let _ = delta_tx.try_send(ProtocolDelta::ClientMoved {
client_id: client_id.0 as u64,
@@ -1115,10 +1119,7 @@ fn send_text_to_mode(
async fn fetch_client_profile(
con: &mut Connection,
client_id: u64,
voice_in_tx: &mpsc::Sender<InboundVoice>,
chat_tx: &mpsc::Sender<ChatMessage>,
activity_tx: &mpsc::Sender<ServerActivity>,
delta_tx: &mpsc::Sender<ProtocolDelta>,
channels: &EventChannels,
pending_moves: &mut PendingMoves,
voice_activity: &mut HashMap<u64, Instant>,
) -> Result<ClientProfile, ProtocolError> {
@@ -1155,10 +1156,7 @@ async fn fetch_client_profile(
let _ = request_messages(
con,
build_command("servergrouplist", &[], &[]),
voice_in_tx,
chat_tx,
activity_tx,
delta_tx,
channels,
pending_moves,
voice_activity,
)
@@ -1168,10 +1166,7 @@ async fn fetch_client_profile(
let _ = request_messages(
con,
build_command("channelgrouplist", &[], &[]),
voice_in_tx,
chat_tx,
activity_tx,
delta_tx,
channels,
pending_moves,
voice_activity,
)
@@ -1185,10 +1180,7 @@ async fn fetch_client_profile(
&[("clid", client_id.to_string())],
&[],
),
voice_in_tx,
chat_tx,
activity_tx,
delta_tx,
channels,
pending_moves,
voice_activity,
)
@@ -1206,10 +1198,7 @@ async fn fetch_client_profile(
if let Err(e) = request_messages(
con,
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
voice_in_tx,
chat_tx,
activity_tx,
delta_tx,
channels,
pending_moves,
voice_activity,
)
@@ -1228,10 +1217,7 @@ async fn fetch_client_profile(
request_client_db_info(
con,
database_id,
voice_in_tx,
chat_tx,
activity_tx,
delta_tx,
channels,
pending_moves,
voice_activity,
)
@@ -1389,10 +1375,7 @@ fn client_profile_refresh_plan(
async fn request_messages(
con: &mut Connection,
command: OutCommand,
voice_in_tx: &mpsc::Sender<InboundVoice>,
chat_tx: &mpsc::Sender<ChatMessage>,
activity_tx: &mpsc::Sender<ServerActivity>,
delta_tx: &mpsc::Sender<ProtocolDelta>,
channels: &EventChannels,
pending_moves: &mut PendingMoves,
voice_activity: &mut HashMap<u64, Instant>,
) -> Result<Vec<InMessage>, ProtocolError> {
@@ -1428,14 +1411,14 @@ async fn request_messages(
return Ok(messages);
}
StreamItem::Audio(buf) => {
handle_audio_stream_item(voice_in_tx, voice_activity, buf).await;
handle_audio_stream_item(&channels.voice_in, voice_activity, buf).await;
}
other => handle_non_audio_stream_item(
con,
other,
chat_tx,
activity_tx,
delta_tx,
&channels.chat,
&channels.activity,
&channels.delta,
pending_moves,
),
}
@@ -1445,20 +1428,14 @@ async fn request_messages(
async fn request_client_db_info(
con: &mut Connection,
dbid: tsclientlib::ClientDbId,
voice_in_tx: &mpsc::Sender<InboundVoice>,
chat_tx: &mpsc::Sender<ChatMessage>,
activity_tx: &mpsc::Sender<ServerActivity>,
delta_tx: &mpsc::Sender<ProtocolDelta>,
channels: &EventChannels,
pending_moves: &mut PendingMoves,
voice_activity: &mut HashMap<u64, Instant>,
) -> Result<InClientDbInfoPart, ProtocolError> {
let messages = request_messages(
con,
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
voice_in_tx,
chat_tx,
activity_tx,
delta_tx,
channels,
pending_moves,
voice_activity,
)
@@ -1781,9 +1758,7 @@ fn format_server_activity(con: &Connection, ev: &tsclientlib::events::Event) ->
extra,
..
} => {
if extra.reason.is_none() {
return None;
}
extra.reason?;
let client = activity_client(con, *client_id)?;
let channel = activity_channel_name(con, client.channel)?;
Some(format!(
@@ -2161,7 +2136,7 @@ fn forward_delta(
id: PropertyId::Client(client_id),
..
} => {
if let Some(state) = con.get_state().ok() {
if let Ok(state) = con.get_state() {
if let Some(client) = state.clients.get(client_id) {
let _ = delta_tx.try_send(ProtocolDelta::ClientJoined {
client_id: client_id.0 as u64,
@@ -2178,15 +2153,13 @@ fn forward_delta(
}
Event::PropertyRemoved {
id: PropertyId::Client(_),
old,
old: PropertyValue::Client(client),
..
} => {
if let PropertyValue::Client(client) = old {
let _ = delta_tx.try_send(ProtocolDelta::ClientLeft {
client_id: client.id.0 as u64,
name: client.name.clone(),
});
}
let _ = delta_tx.try_send(ProtocolDelta::ClientLeft {
client_id: client.id.0 as u64,
name: client.name.clone(),
});
}
Event::PropertyChanged {
id: PropertyId::ClientChannel(_),
@@ -2196,7 +2169,7 @@ fn forward_delta(
id: PropertyId::Client(client_id),
..
} => {
if let Some(state) = con.get_state().ok() {
if let Ok(state) = con.get_state() {
if let Some(client) = state.clients.get(client_id) {
let _ = delta_tx.try_send(ProtocolDelta::ClientUpdated {
client_id: client_id.0 as u64,
@@ -2213,7 +2186,7 @@ fn forward_delta(
id: PropertyId::Channel(channel_id),
..
} => {
if let Some(state) = con.get_state().ok() {
if let Ok(state) = con.get_state() {
if let Some(channel) = state.channels.get(channel_id) {
let _ = delta_tx.try_send(ProtocolDelta::ChannelAdded {
id: channel_id.0,
@@ -2228,20 +2201,18 @@ fn forward_delta(
}
Event::PropertyRemoved {
id: PropertyId::Channel(_),
old,
old: PropertyValue::Channel(channel),
..
} => {
if let PropertyValue::Channel(channel) = old {
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved {
id: channel.id.0,
});
}
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved {
id: channel.id.0,
});
}
Event::PropertyChanged {
id: PropertyId::Channel(channel_id),
..
} => {
if let Some(state) = con.get_state().ok() {
if let Ok(state) = con.get_state() {
if let Some(channel) = state.channels.get(channel_id) {
let _ = delta_tx.try_send(ProtocolDelta::ChannelUpdated {
id: channel_id.0,
+41
View File
@@ -168,52 +168,93 @@ pub struct ServerSnapshot {
}
impl ChannelId {
/// Root/top-level channel identifier.
pub const ROOT: ChannelId = ChannelId(0);
}
/// Incremental state change emitted by the protocol adapter when the server tree changes.
///
/// Covers client joins, leaves, moves, and channel additions, removals, and updates.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProtocolDelta {
/// A client moved to a different channel.
ClientMoved {
/// Unique client identifier.
client_id: u64,
/// Destination channel identifier.
new_channel_id: u64,
},
/// A new client appeared on the server.
ClientJoined {
/// Unique client identifier.
client_id: u64,
/// Channel the client joined.
channel_id: u64,
/// Display nickname.
name: String,
/// Microphone muted state.
input_muted: bool,
/// Speaker muted state.
output_muted: bool,
/// Whether the client is a server query client.
is_server_query: bool,
/// Client's talk power value.
talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool,
},
/// A client disconnected.
ClientLeft {
/// Unique client identifier.
client_id: u64,
/// Display nickname.
name: String,
},
/// A client's properties changed.
ClientUpdated {
/// Unique client identifier.
client_id: u64,
/// Microphone muted state.
input_muted: bool,
/// Speaker muted state.
output_muted: bool,
/// Whether the client is a server query client.
is_server_query: bool,
/// Client's talk power value.
talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool,
},
/// A new channel appeared.
ChannelAdded {
/// Channel identifier.
id: u64,
/// Parent channel identifier.
parent: u64,
/// Channel name.
name: String,
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
order: i64,
/// Whether the channel has a password.
has_password: bool,
/// Needed talk power, or `None` when unrestricted.
needed_talk_power: Option<i32>,
},
/// A channel was deleted.
ChannelRemoved {
/// Channel identifier.
id: u64,
},
/// Channel properties changed.
ChannelUpdated {
/// Channel identifier.
id: u64,
/// Channel name.
name: String,
/// Whether the channel has a password.
has_password: bool,
/// Needed talk power, or `None` when unrestricted.
needed_talk_power: Option<i32>,
},
}
+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
/// without poking a real OS keyring (which would either prompt the
/// 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 {
matches!(
std::env::var("CHANORA_DISABLE_KEYRING").as_deref(),
+210 -1
View File
@@ -16,7 +16,7 @@ those terms.
| License | Crate count |
|---------|-------------|
| `Apache License 2.0` | 331 |
| `Apache License 2.0` | 333 |
| `MIT License` | 74 |
| `Unicode License v3` | 19 |
| `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> |
| 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> |
| 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> |
| 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> |
@@ -144,6 +145,7 @@ those terms.
| 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-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> |
| 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> |
@@ -4993,6 +4995,213 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
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.
To apply the Apache License to your work, attach the following