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