diff --git a/apps/chanora_flutter/android/gradle.properties b/apps/chanora_flutter/android/gradle.properties index fbee1d8..d5da727 100644 --- a/apps/chanora_flutter/android/gradle.properties +++ b/apps/chanora_flutter/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/apps/chanora_flutter/ios/Podfile.lock b/apps/chanora_flutter/ios/Podfile.lock index 8f9c544..2e5d79c 100644 --- a/apps/chanora_flutter/ios/Podfile.lock +++ b/apps/chanora_flutter/ios/Podfile.lock @@ -1,69 +1,32 @@ PODS: - - audio_session (0.0.1): - - Flutter - chanora_bridge (1.0.0) - - connectivity_plus (0.0.1): - - Flutter - Flutter (1.0.0) - flutter_foreground_task (0.0.1): - Flutter - haptic_kit (1.0.0): - Flutter - - package_info_plus (0.4.5): - - Flutter - - share_plus (0.0.1): - - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - url_launcher_ios (0.0.1): - - Flutter DEPENDENCIES: - - audio_session (from `.symlinks/plugins/audio_session/ios`) - chanora_bridge (from `.`) - - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) - flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`) - haptic_kit (from `.symlinks/plugins/haptic_kit/ios`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - share_plus (from `.symlinks/plugins/share_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) EXTERNAL SOURCES: - audio_session: - :path: ".symlinks/plugins/audio_session/ios" chanora_bridge: :path: "." - connectivity_plus: - :path: ".symlinks/plugins/connectivity_plus/ios" Flutter: :path: Flutter flutter_foreground_task: :path: ".symlinks/plugins/flutter_foreground_task/ios" haptic_kit: :path: ".symlinks/plugins/haptic_kit/ios" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" - share_plus: - :path: ".symlinks/plugins/share_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 chanora_bridge: 2ed7c2ba427fab135dd9eab66c507b09cfee113a - connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89 haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b PODFILE CHECKSUM: e2123068539aeb66d53dc1612b383d13f489ede2 diff --git a/apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj b/apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj index 56513e7..4d081eb 100644 --- a/apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; FD3C80659716BF7A0C95C7AF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -72,6 +73,7 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -79,6 +81,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 8C5000042DD0000000000001 /* SileroCoreML in Frameworks */, 1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */, ); @@ -128,6 +131,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -216,6 +220,7 @@ ); name = Runner; packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, 8C5000032DD0000000000001 /* SileroCoreML */, ); productName = Runner; @@ -252,6 +257,7 @@ ); mainGroup = 97C146E51CF9000F007C117D; packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */, ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; @@ -760,6 +766,10 @@ isa = XCLocalSwiftPackageReference; relativePath = ../../../silero-coreml; }; + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -768,6 +778,10 @@ package = 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */; productName = SileroCoreML; }; + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/apps/chanora_flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/chanora_flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..c3fedb2 100644 --- a/apps/chanora_flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/apps/chanora_flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + with WidgetsBindingObserver { rust.BridgeSnapshot? _snapshot; final List _uiDiagnostics = []; rust.BridgeAudioStats? _audioStats; + double? _inputLevel; Timer? _statsTimer; bool _snapshotRefreshInFlight = false; bool _snapshotRefreshQueued = false; @@ -319,6 +320,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { bool _snapshotRefreshQueuedReportErrors = false; int _connectionEpoch = 0; StreamSubscription? _eventsSub; + StreamSubscription? _inputLevelSub; late final PrefetchDebouncer _prefetch; // v1 voice subsystem state (SDD-094/095/096/097). Driven by @@ -395,6 +397,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _prefetch = PrefetchDebouncer(onPrefetch: _onPrefetchServer); _hostCtl.addListener(_onHostEdited); _eventsSub = rust.eventsStream().listen(_onEvent); + _inputLevelSub = rust.inputLevelStream().listen((level) { + if (mounted) setState(() => _inputLevel = level); + }); // SDD-106 §5: subscribe to Kotlin -> Dart permissionStateChanged // events as early as possible so the listen-only banner reflects // the system state on first frame. @@ -957,6 +962,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { WidgetsBinding.instance.removeObserver(this); HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey); _eventsSub?.cancel(); + _inputLevelSub?.cancel(); _statsTimer?.cancel(); _snapshotRefreshInFlight = false; _snapshotRefreshQueued = false; @@ -2378,6 +2384,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _currentVoiceChannelId, ), audioStats: _audioStats, + inputLevel: _inputLevel, pttLevel: _pttLevel, pttBackendId: _pttBackendId, pttBoundInputClass: _pttBoundInputClass, diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 2f96d0b..b0e13e6 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -261,6 +261,12 @@ Stream eventsStream() => Future audioStats() => RustLib.instance.api.crateApiAudioStats(); +/// Subscribe to real-time microphone input level at ~30 Hz. +/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends +/// when the Dart subscriber cancels or the session is dropped. +Stream inputLevelStream() => + RustLib.instance.api.crateApiInputLevelStream(); + /// Apply the P1 audio-processing config. Future setAudioProcessingConfig({ required BridgeAudioProcessingConfig config, @@ -681,15 +687,22 @@ class BridgeAudioStats { /// Current push-to-talk state. final bool pttActive; + /// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping). + final double inputLevel; + const BridgeAudioStats({ required this.framesSent, required this.framesReceived, required this.pttActive, + required this.inputLevel, }); @override int get hashCode => - framesSent.hashCode ^ framesReceived.hashCode ^ pttActive.hashCode; + framesSent.hashCode ^ + framesReceived.hashCode ^ + pttActive.hashCode ^ + inputLevel.hashCode; @override bool operator ==(Object other) => @@ -698,7 +711,8 @@ class BridgeAudioStats { runtimeType == other.runtimeType && framesSent == other.framesSent && framesReceived == other.framesReceived && - pttActive == other.pttActive; + pttActive == other.pttActive && + inputLevel == other.inputLevel; } /// Bookmark DTO mirroring [`chanora_core::Bookmark`]. diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index edb74e8..c662636 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 281698435; + int get rustContentHash => -20394775; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -123,6 +123,8 @@ abstract class RustLibApi extends BaseApi { Future crateApiInitStorage({required String dir}); + Stream crateApiInputLevelStream(); + Future crateApiIsConnected(); Future crateApiListAudioDevices(); @@ -762,6 +764,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiInitStorageConstMeta => const TaskConstMeta(debugName: "init_storage", argNames: ["dir"]); + @override + Stream crateApiInputLevelStream() { + final sink = RustStreamSink(); + unawaited( + handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_StreamSink_f_32_Sse(sink, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 21, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiInputLevelStreamConstMeta, + argValues: [sink], + apiImpl: this, + ), + ), + ); + return sink.stream; + } + + TaskConstMeta get kCrateApiInputLevelStreamConstMeta => + const TaskConstMeta(debugName: "input_level_stream", argNames: ["sink"]); + @override Future crateApiIsConnected() { return handler.executeNormal( @@ -771,7 +805,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 21, + funcId: 22, port: port_, ); }, @@ -798,7 +832,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 22, + funcId: 23, port: port_, ); }, @@ -825,7 +859,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 23, + funcId: 24, port: port_, ); }, @@ -849,7 +883,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -879,7 +913,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 25, + funcId: 26, port: port_, ); }, @@ -909,7 +943,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 26, + funcId: 27, port: port_, ); }, @@ -936,7 +970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 27, + funcId: 28, port: port_, ); }, @@ -961,7 +995,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(state, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -994,7 +1028,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 29, + funcId: 30, port: port_, ); }, @@ -1021,7 +1055,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_bridge_audio_route(route, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1055,7 +1089,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 31, + funcId: 32, port: port_, ); }, @@ -1090,7 +1124,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 32, + funcId: 33, port: port_, ); }, @@ -1120,7 +1154,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 33, + funcId: 34, port: port_, ); }, @@ -1148,7 +1182,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 34, + funcId: 35, port: port_, ); }, @@ -1176,7 +1210,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 35, + funcId: 36, port: port_, ); }, @@ -1206,7 +1240,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 36, + funcId: 37, port: port_, ); }, @@ -1234,7 +1268,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_bridge_network_state(state, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1260,7 +1294,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 38, + funcId: 39, port: port_, ); }, @@ -1288,7 +1322,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 39, + funcId: 40, port: port_, ); }, @@ -1316,7 +1350,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 40, + funcId: 41, port: port_, ); }, @@ -1344,7 +1378,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 41, + funcId: 42, port: port_, ); }, @@ -1376,7 +1410,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 42, + funcId: 43, port: port_, ); }, @@ -1406,7 +1440,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 43, + funcId: 44, port: port_, ); }, @@ -1434,7 +1468,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 44, + funcId: 45, port: port_, ); }, @@ -1462,7 +1496,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 45, + funcId: 46, port: port_, ); }, @@ -1489,7 +1523,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 46, + funcId: 47, port: port_, ); }, @@ -1517,7 +1551,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 47, + funcId: 48, port: port_, ); }, @@ -1549,7 +1583,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 48, + funcId: 49, port: port_, ); }, @@ -1578,7 +1612,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 49, + funcId: 50, port: port_, ); }, @@ -1610,6 +1644,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { throw UnimplementedError(); } + @protected + RustStreamSink dco_decode_StreamSink_f_32_Sse(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(); + } + @protected String dco_decode_String(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1781,12 +1821,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 3) - throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); + if (arr.length != 4) + throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); return BridgeAudioStats( framesSent: dco_decode_u_32(arr[0]), framesReceived: dco_decode_u_32(arr[1]), pttActive: dco_decode_bool(arr[2]), + inputLevel: dco_decode_f_32(arr[3]), ); } @@ -2271,6 +2312,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { throw UnimplementedError('Unreachable ()'); } + @protected + RustStreamSink sse_decode_StreamSink_f_32_Sse( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + throw UnimplementedError('Unreachable ()'); + } + @protected String sse_decode_String(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -2488,10 +2537,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_framesSent = sse_decode_u_32(deserializer); var var_framesReceived = sse_decode_u_32(deserializer); var var_pttActive = sse_decode_bool(deserializer); + var var_inputLevel = sse_decode_f_32(deserializer); return BridgeAudioStats( framesSent: var_framesSent, framesReceived: var_framesReceived, pttActive: var_pttActive, + inputLevel: var_inputLevel, ); } @@ -3199,6 +3250,23 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + void sse_encode_StreamSink_f_32_Sse( + RustStreamSink self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String( + self.setupAndSerialize( + codec: SseCodec( + decodeSuccessData: sse_decode_f_32, + decodeErrorData: sse_decode_AnyhowException, + ), + ), + serializer, + ); + } + @protected void sse_encode_String(String self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -3380,6 +3448,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_32(self.framesSent, serializer); sse_encode_u_32(self.framesReceived, serializer); sse_encode_bool(self.pttActive, serializer); + sse_encode_f_32(self.inputLevel, serializer); } @protected diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart index dbde565..a4cfb75 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart @@ -27,6 +27,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { dynamic raw, ); + @protected + RustStreamSink dco_decode_StreamSink_f_32_Sse(dynamic raw); + @protected String dco_decode_String(dynamic raw); @@ -210,6 +213,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + RustStreamSink sse_decode_StreamSink_f_32_Sse( + SseDeserializer deserializer, + ); + @protected String sse_decode_String(SseDeserializer deserializer); @@ -439,6 +447,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_StreamSink_f_32_Sse( + RustStreamSink self, + SseSerializer serializer, + ); + @protected void sse_encode_String(String self, SseSerializer serializer); diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart index f345a9b..9b269ba 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart @@ -29,6 +29,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { dynamic raw, ); + @protected + RustStreamSink dco_decode_StreamSink_f_32_Sse(dynamic raw); + @protected String dco_decode_String(dynamic raw); @@ -212,6 +215,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + RustStreamSink sse_decode_StreamSink_f_32_Sse( + SseDeserializer deserializer, + ); + @protected String sse_decode_String(SseDeserializer deserializer); @@ -441,6 +449,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_StreamSink_f_32_Sse( + RustStreamSink self, + SseSerializer serializer, + ); + @protected void sse_encode_String(String self, SseSerializer serializer); diff --git a/apps/chanora_flutter/lib/widgets/voice_bar.dart b/apps/chanora_flutter/lib/widgets/voice_bar.dart index ca3f30f..bf425c0 100644 --- a/apps/chanora_flutter/lib/widgets/voice_bar.dart +++ b/apps/chanora_flutter/lib/widgets/voice_bar.dart @@ -33,6 +33,7 @@ class VoiceBar extends StatelessWidget { required this.onConfigure, required this.onPttHeldChanged, this.talkPowerBlocked = false, + this.inputLevel, }); final bool inChannel; @@ -53,6 +54,10 @@ class VoiceBar extends StatelessWidget { /// level meter. Pass `null` to render an idle meter. final rust.BridgeAudioStats? audioStats; + /// Real-time input level from the 30 Hz stream (dBFS). + /// When non-null, takes precedence over `audioStats.inputLevel`. + final double? inputLevel; + /// PTT capability badge inputs — passed through to /// [`PttCapabilityBadge`]. final String pttLevel; @@ -196,7 +201,7 @@ class VoiceBar extends StatelessWidget { ), const SizedBox(height: 6), // Row 4: level meter - VoiceLevelMeter(active: levelActive), + VoiceLevelMeter(active: levelActive, level: inputLevel ?? stats?.inputLevel), const SizedBox(height: 4), if (stats != null) Text( diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 601c46e..e52c599 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -7,7 +7,7 @@ // release-tail are surfaced inline (radio buttons + slider) inside // the modal. -import 'dart:async' show Timer, unawaited; +import 'dart:async' show StreamSubscription, Timer, unawaited; import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show kIsWeb; @@ -455,6 +455,8 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { int _rateTickCount = 0; late final AudioProcessingConfigState _audioProcessing; + double? _streamLevel; + StreamSubscription? _levelSub; @override void initState() { @@ -463,8 +465,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { widget.initialAudioConfig, ); - // Poll audio stats at 250 ms so TX/RX counters and the level meter - // update in real time while the sheet is open, independent of the parent. + _levelSub = rust.inputLevelStream().listen((level) { + if (mounted) setState(() => _streamLevel = level); + }); + + // Poll audio stats at 250 ms so TX/RX counters update in real time + // while the sheet is open. _statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async { try { final s = await rust.audioStats(); @@ -472,7 +478,6 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { setState(() { _stats = s; _rateTickCount++; - // Compute rates every ~1 s (4 × 250 ms). if (_rateTickCount >= 4) { _txRate = s.framesSent - _prevSent; _rxRate = s.framesReceived - _prevReceived; @@ -487,6 +492,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { @override void dispose() { + _levelSub?.cancel(); _statsTimer?.cancel(); super.dispose(); } @@ -625,7 +631,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { const SizedBox(height: 12), // 4) Level meter + live TX/RX stats. - VoiceLevelMeter(active: levelActive), + VoiceLevelMeter(active: levelActive, level: _streamLevel ?? stats?.inputLevel), const SizedBox(height: 6), _StatsRow( txRate: _txRate, diff --git a/apps/chanora_flutter/lib/widgets/voice_level_meter.dart b/apps/chanora_flutter/lib/widgets/voice_level_meter.dart index 87fce59..c94433f 100644 --- a/apps/chanora_flutter/lib/widgets/voice_level_meter.dart +++ b/apps/chanora_flutter/lib/widgets/voice_level_meter.dart @@ -1,28 +1,78 @@ +import 'dart:math' show max; + import 'package:flutter/material.dart'; /// Shared compact level meter used by voice surfaces. -class VoiceLevelMeter extends StatelessWidget { - const VoiceLevelMeter({super.key, required this.active}); +/// +/// When [level] is null (no stats available yet), falls back to [active] +/// for a binary indicator. When [level] is provided it is interpreted as +/// dBFS and mapped to a 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; + /// 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 createState() => _VoiceLevelMeterState(); +} + +class _VoiceLevelMeterState extends State { + double _previousFill = 0.0; + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final double fill; + final Color color; + if (widget.level != null) { + fill = VoiceLevelMeter.dbfsToFraction(widget.level!); + color = fill > 0.0 + ? theme.colorScheme.primary + : theme.colorScheme.outlineVariant; + } else { + fill = widget.active ? 0.75 : 0.05; + color = widget.active + ? theme.colorScheme.primary + : theme.colorScheme.outlineVariant; + } + + final begin = _previousFill; + _previousFill = fill; + return Container( height: 8, decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), - child: FractionallySizedBox( - alignment: AlignmentDirectional.centerStart, - widthFactor: active ? 0.75 : 0.05, + child: TweenAnimationBuilder( + tween: Tween(begin: begin, end: fill), + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + builder: (context, animatedFill, child) { + return FractionallySizedBox( + alignment: AlignmentDirectional.centerStart, + widthFactor: max(animatedFill, 0.02), + child: child, + ); + }, child: Container( decoration: BoxDecoration( - color: active - ? theme.colorScheme.primary - : theme.colorScheme.outlineVariant, + color: color, borderRadius: BorderRadius.circular(4), ), ), diff --git a/apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework/Versions/A/Resources/Info.plist b/apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework/Versions/A/Resources/Info.plist index 1262641..4758774 100644 --- a/apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework/Versions/A/Resources/Info.plist +++ b/apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework/Versions/A/Resources/Info.plist @@ -9,6 +9,6 @@ CFBundleShortVersionString1.0.0 CFBundleVersion1 CFBundleSupportedPlatformsMacOSX - MinimumOSVersion10.15 + MinimumOSVersion13.0 diff --git a/apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework/Versions/A/chanora_bridge b/apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework/Versions/A/chanora_bridge index 3027e40..f103199 100755 Binary files a/apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework/Versions/A/chanora_bridge and b/apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework/Versions/A/chanora_bridge differ diff --git a/apps/chanora_flutter/macos/Podfile.lock b/apps/chanora_flutter/macos/Podfile.lock index 637f4db..8cc430c 100644 --- a/apps/chanora_flutter/macos/Podfile.lock +++ b/apps/chanora_flutter/macos/Podfile.lock @@ -1,57 +1,20 @@ PODS: - - audio_session (0.0.1): - - FlutterMacOS - chanora_bridge (1.0.0) - - connectivity_plus (0.0.1): - - FlutterMacOS - FlutterMacOS (1.0.0) - - package_info_plus (0.0.1): - - FlutterMacOS - - share_plus (0.0.1): - - FlutterMacOS - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - url_launcher_macos (0.0.1): - - FlutterMacOS DEPENDENCIES: - - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) - chanora_bridge (from `/Users/edison/dev/chanora/apps/chanora_flutter/macos`) - - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) EXTERNAL SOURCES: - audio_session: - :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos chanora_bridge: :path: "/Users/edison/dev/chanora/apps/chanora_flutter/macos" - connectivity_plus: - :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos FlutterMacOS: :path: Flutter/ephemeral - package_info_plus: - :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos - share_plus: - :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos - shared_preferences_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin - url_launcher_macos: - :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: - audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e chanora_bridge: 4105993843b5421ee4ce72220a74c63f6fd99103 - connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb diff --git a/apps/chanora_flutter/macos/Runner.xcodeproj/project.pbxproj b/apps/chanora_flutter/macos/Runner.xcodeproj/project.pbxproj index 23801f8..b6d02ea 100644 --- a/apps/chanora_flutter/macos/Runner.xcodeproj/project.pbxproj +++ b/apps/chanora_flutter/macos/Runner.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 45F255D1DE0134185DB5423D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 06E1AA7E1FB968C1D78DA8DE /* PrivacyInfo.xcprivacy */; }; 9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */; }; C2DC22E19FDCE26B9D79442E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -93,6 +94,7 @@ ED6F5EE0C5FD4DA22D79188C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -108,6 +110,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 8C6000042DD0000000000001 /* SileroCoreML in Frameworks */, 9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */, ); @@ -170,6 +173,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -256,6 +260,7 @@ ); name = Runner; packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, 8C6000032DD0000000000001 /* SileroCoreML */, ); productName = Runner; @@ -302,6 +307,7 @@ ); mainGroup = 33CC10E42044A3C60003C045; packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, 8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */, ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; @@ -865,6 +871,10 @@ isa = XCLocalSwiftPackageReference; relativePath = ../../../silero-coreml; }; + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -873,6 +883,10 @@ package = 8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */; productName = SileroCoreML; }; + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; diff --git a/apps/chanora_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/chanora_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 132ce99..65fdbad 100644 --- a/apps/chanora_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/apps/chanora_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Result<(u32, u32, bool), CoreError> { + /// Read audio engine statistics: (frames_sent, frames_received, transmit_active, input_level_dbfs). + pub async fn audio_stats(&self) -> Result<(u32, u32, bool, f32), CoreError> { let guard = self.inner.lock().await; let state = guard.as_ref().ok_or(CoreError::NotConnected)?; let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?; @@ -1354,6 +1354,7 @@ impl ChanoraSession { audio.frames_sent(), audio.frames_received(), audio.transmit_active(), + audio.input_level(), )) } diff --git a/crates/chanora_audio/src/audio_processing.rs b/crates/chanora_audio/src/audio_processing.rs index 26adbe0..c385415 100644 --- a/crates/chanora_audio/src/audio_processing.rs +++ b/crates/chanora_audio/src/audio_processing.rs @@ -404,6 +404,19 @@ impl Default for SharedAudioProcessingStats { } impl SharedAudioProcessingStats { + /// Store the raw input dBFS level (desktop capture path). + /// Mobile platforms use [`Self::update_capture`] instead, which + /// also records VAD state; this lighter method is for the cpal + /// capture path that has no VAD pipeline. + pub fn set_input_dbfs(&self, dbfs: f32) { + self.input_dbfs.store(dbfs.to_bits(), Ordering::Relaxed); + } + + /// Read the current input dBFS level. + pub fn input_dbfs(&self) -> f32 { + f32::from_bits(self.input_dbfs.load(Ordering::Relaxed)) + } + /// Store capture levels and VAD state. pub fn update_capture( &self, diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index c55a235..bf316e0 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -838,6 +838,7 @@ impl AudioEngine { transmit_flag_for_capture, frames_sent.clone(), cfg.mic_gain, + audio_processing_stats.clone(), ); let (input_stream, capture_active) = match capture_result { Ok(s) => (Some(s), true), @@ -1597,6 +1598,11 @@ impl AudioEngine { self.frames_received.load(Ordering::Relaxed) } + /// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping). + pub fn input_level(&self) -> f32 { + self.audio_processing_stats.input_dbfs() + } + /// Current audio-processing config snapshot. pub fn audio_processing_config_snapshot(&self) -> crate::AudioProcessingConfig { self.audio_processing_config.lock().unwrap().clone() @@ -1717,6 +1723,7 @@ fn try_open_capture( transmit_active: Arc, frames_sent: Arc, mic_gain: f32, + audio_processing_stats: Arc, ) -> Result { let in_cfg = in_dev .default_input_config() @@ -1752,6 +1759,7 @@ fn try_open_capture( voice_out_tx, transmit_active, frames_sent, + audio_processing_stats, ))); let stream = match in_format { @@ -1803,6 +1811,7 @@ struct CaptureState { /// capacity so the drain-into-frame path skips the allocator /// after warmup. Same precedent as `mono_scratch` above. frame_scratch: Vec, + audio_processing_stats: Arc, } #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))] @@ -1815,6 +1824,7 @@ impl CaptureState { voice_out_tx: mpsc::Sender, transmit_active: Arc, frames_sent: Arc, + audio_processing_stats: Arc, ) -> Self { Self { encoder, @@ -1828,12 +1838,9 @@ impl CaptureState { voice_out_tx, transmit_active, frames_sent, - // Generous upper bound for typical cpal periods - // (commonly 256..1024 frames); `clear()` retains the - // backing allocation across callbacks. See struct doc. mono_scratch: Vec::with_capacity(4096), - // Exact upper bound: drain pulls FRAME_SAMPLES at a time. frame_scratch: Vec::with_capacity(FRAME_SAMPLES), + audio_processing_stats, } } @@ -1841,17 +1848,8 @@ impl CaptureState { /// 48 kHz mono frames; encode and send when `transmit_active` /// is true (PTT engaged). fn ingest(&mut self, buf: &[T]) { - if !self.transmit_active.load(Ordering::Relaxed) { - // Drain accumulator while muted so we don't pop on PTT release. - self.pcm_accum.clear(); - return; - } - - // 1. Down-mix to mono + gain. - // Reuse `self.mono_scratch` to avoid a per-callback Vec - // allocation on the realtime audio thread; see struct - // doc and the engine.rs:1389-1397 precedent for why this - // matters for user-perceptible audio popping. + // 1. Down-mix to mono (pre-gain). Always performed so the level + // meter reflects real mic input even when PTT is released. let in_channels = self.in_channels; let mic_gain = self.mic_gain; self.mono_scratch.clear(); @@ -1859,8 +1857,23 @@ impl CaptureState { self.mono_scratch.reserve(frame_count); for frame in buf.chunks(in_channels) { let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum(); - self.mono_scratch - .push((sum / frame.len() as f32) * mic_gain); + self.mono_scratch.push(sum / frame.len() as f32); + } + + // Measure dBFS from pre-gain samples so the level meter + // reflects the raw mic input, not the amplified signal. + self.audio_processing_stats + .set_input_dbfs(crate::frame::dbfs(&self.mono_scratch)); + + if mic_gain != 1.0 { + for s in &mut self.mono_scratch { + *s *= mic_gain; + } + } + + if !self.transmit_active.load(Ordering::Relaxed) { + self.pcm_accum.clear(); + return; } // 2. Resample to 48 kHz if needed. We re-borrow @@ -2488,6 +2501,7 @@ pub mod bench_seam { tx, transmit_active.clone(), frames_sent, + Arc::new(crate::SharedAudioProcessingStats::default()), ); Self { state, diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index 8aa4a2c..6545068 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -1038,6 +1038,8 @@ pub struct BridgeAudioStats { pub frames_received: u32, /// Current push-to-talk state. pub ptt_active: bool, + /// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping). + pub input_level: f32, } /// Bridge route class for P1 audio-processing policy. @@ -2096,7 +2098,7 @@ pub fn events_stream(sink: StreamSink) -> Result<(), BridgeError> { /// Read audio statistics. Errors if no connection or audio not started. pub async fn audio_stats() -> Result { - let (s, r, p) = runtime() + let (s, r, p, lvl) = runtime() .spawn(async { session().audio_stats().await }) .await .map_err(|e| task_join_error("audio_stats", e))??; @@ -2104,9 +2106,42 @@ pub async fn audio_stats() -> Result { frames_sent: s, frames_received: r, ptt_active: p, + input_level: lvl, }) } +/// Subscribe to real-time microphone input level at ~30 Hz. +/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends +/// when the Dart subscriber cancels, the session is dropped, or +/// the session becomes persistently unavailable. +pub fn input_level_stream(sink: StreamSink) -> Result<(), BridgeError> { + runtime().spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(33)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut consecutive_errors = 0u32; + loop { + interval.tick().await; + let level = match session().audio_stats().await { + Ok((_, _, _, lvl)) => { + consecutive_errors = 0; + lvl + } + Err(_) => { + consecutive_errors += 1; + if consecutive_errors >= 10 { + return; + } + -120.0 + } + }; + if sink.add(level).is_err() { + return; + } + } + }); + Ok(()) +} + /// Apply the P1 audio-processing config. pub async fn set_audio_processing_config( config: BridgeAudioProcessingConfig, diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index c7aa75c..00b6301 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 281698435; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -20394775; // Section: executor @@ -738,6 +738,42 @@ fn wire__crate__api__init_storage_impl( }, ) } +fn wire__crate__api__input_level_stream_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::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 = + >::sse_decode( + &mut deserializer, + ); + deserializer.end(); + move |context| { + transform_result_sse::<_, crate::BridgeError>((move || { + let output_ok = crate::api::input_level_stream(api_sink)?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__is_connected_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1790,6 +1826,14 @@ impl SseDecode } } +impl SseDecode for StreamSink { + // 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 = ::sse_decode(deserializer); + return StreamSink::deserialize(inner); + } +} + impl SseDecode for String { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1962,10 +2006,12 @@ impl SseDecode for crate::api::BridgeAudioStats { let mut var_framesSent = ::sse_decode(deserializer); let mut var_framesReceived = ::sse_decode(deserializer); let mut var_pttActive = ::sse_decode(deserializer); + let mut var_inputLevel = ::sse_decode(deserializer); return crate::api::BridgeAudioStats { frames_sent: var_framesSent, frames_received: var_framesReceived, ptt_active: var_pttActive, + input_level: var_inputLevel, }; } } @@ -2755,33 +2801,34 @@ fn pde_ffi_dispatcher_primary_impl( 14 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), 15 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len), 20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), - 21 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), - 22 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len), - 23 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len), - 25 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len), - 26 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len), - 27 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len), - 29 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len), - 31 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len), - 32 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len), - 33 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len), - 34 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len), - 35 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len), - 36 => { + 21 => wire__crate__api__input_level_stream_impl(port, ptr, rust_vec_len, data_len), + 22 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), + 23 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len), + 24 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len), + 26 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len), + 27 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len), + 28 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len), + 30 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len), + 32 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len), + 33 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len), + 34 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len), + 35 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len), + 36 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len), + 37 => { wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len) } - 38 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len), - 39 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len), - 40 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len), - 41 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), - 42 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len), - 43 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), - 44 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len), - 45 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len), - 46 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), - 47 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), - 48 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len), - 49 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len), + 39 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len), + 40 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len), + 41 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len), + 42 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), + 43 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len), + 44 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), + 45 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len), + 46 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len), + 47 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), + 48 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), + 49 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len), + 50 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -2803,10 +2850,10 @@ fn pde_ffi_dispatcher_sync_impl( data_len, ), 19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len), - 24 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len), - 28 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len), - 30 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len), - 37 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), + 25 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len), + 29 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len), + 31 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len), + 38 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -2984,6 +3031,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioStats { self.frames_sent.into_into_dart().into_dart(), self.frames_received.into_into_dart().into_dart(), self.ptt_active.into_into_dart().into_dart(), + self.input_level.into_into_dart().into_dart(), ] .into_dart() } @@ -3652,6 +3700,13 @@ impl SseEncode } } +impl SseEncode for StreamSink { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + unimplemented!("") + } +} + impl SseEncode for String { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -3781,6 +3836,7 @@ impl SseEncode for crate::api::BridgeAudioStats { ::sse_encode(self.frames_sent, serializer); ::sse_encode(self.frames_received, serializer); ::sse_encode(self.ptt_active, serializer); + ::sse_encode(self.input_level, serializer); } } diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index e707ae1..0d3b2bd 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -56,6 +56,13 @@ type PendingMoves = HashMap< ), >; +struct EventChannels { + voice_in: mpsc::Sender, + chat: mpsc::Sender, + activity: mpsc::Sender, + delta: mpsc::Sender, +} + #[derive(Debug, PartialEq, Eq)] enum SendTimeoutError { Timeout(T), @@ -278,10 +285,12 @@ impl ProtocolClient { cfg.clone(), rx, voice_out_rx, - voice_in_tx, - chat_tx, - activity_tx, - delta_tx, + EventChannels { + voice_in: voice_in_tx, + chat: chat_tx, + activity: activity_tx, + delta: delta_tx, + }, ready_tx, lost_tx, )); @@ -502,10 +511,7 @@ async fn connection_task( cfg: ConnectConfig, mut rx: mpsc::Receiver, mut voice_out_rx: mpsc::Receiver, - voice_in_tx: mpsc::Sender, - chat_tx: mpsc::Sender, - activity_tx: mpsc::Sender, - delta_tx: mpsc::Sender, + channels: EventChannels, ready_tx: oneshot::Sender>, lost_tx: oneshot::Sender, ) { @@ -686,14 +692,14 @@ async fn connection_task( Ok(Some(Ok(item))) => { match item { StreamItem::Audio(buf) => { - handle_audio_stream_item(&voice_in_tx, &mut voice_activity, buf).await; + handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await; } other => handle_non_audio_stream_item( &con, other, - &chat_tx, - &activity_tx, - &delta_tx, + &channels.chat, + &channels.activity, + &channels.delta, &mut pending_moves, ), } @@ -730,10 +736,8 @@ async fn connection_task( }) .collect(); for handle in expired { - if let Some((_target_channel, reply, _)) = pending_moves.remove(&handle) { - if let Some(reply) = reply { - let _ = reply.send(Ok(())); - } + if let Some((_target_channel, Some(reply), _)) = pending_moves.remove(&handle) { + let _ = reply.send(Ok(())); } } } @@ -794,10 +798,7 @@ async fn connection_task( let r = fetch_client_profile( &mut con, client_id, - &voice_in_tx, - &chat_tx, - &activity_tx, - &delta_tx, + &channels, &mut pending_moves, &mut voice_activity, ) @@ -872,7 +873,7 @@ fn handle_non_audio_stream_item( } = &ev { let own_client = con.get_state().ok().map(|state| state.own_client); - if let Some(state) = con.get_state().ok() { + if let Ok(state) = con.get_state() { if let Some(client) = state.clients.get(client_id) { let _ = delta_tx.try_send(ProtocolDelta::ClientMoved { client_id: client_id.0 as u64, @@ -1118,10 +1119,7 @@ fn send_text_to_mode( async fn fetch_client_profile( con: &mut Connection, client_id: u64, - voice_in_tx: &mpsc::Sender, - chat_tx: &mpsc::Sender, - activity_tx: &mpsc::Sender, - delta_tx: &mpsc::Sender, + channels: &EventChannels, pending_moves: &mut PendingMoves, voice_activity: &mut HashMap, ) -> Result { @@ -1158,10 +1156,7 @@ async fn fetch_client_profile( let _ = request_messages( con, build_command("servergrouplist", &[], &[]), - voice_in_tx, - chat_tx, - activity_tx, - delta_tx, + channels, pending_moves, voice_activity, ) @@ -1171,10 +1166,7 @@ async fn fetch_client_profile( let _ = request_messages( con, build_command("channelgrouplist", &[], &[]), - voice_in_tx, - chat_tx, - activity_tx, - delta_tx, + channels, pending_moves, voice_activity, ) @@ -1188,10 +1180,7 @@ async fn fetch_client_profile( &[("clid", client_id.to_string())], &[], ), - voice_in_tx, - chat_tx, - activity_tx, - delta_tx, + channels, pending_moves, voice_activity, ) @@ -1209,10 +1198,7 @@ async fn fetch_client_profile( if let Err(e) = request_messages( con, build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]), - voice_in_tx, - chat_tx, - activity_tx, - delta_tx, + channels, pending_moves, voice_activity, ) @@ -1231,10 +1217,7 @@ async fn fetch_client_profile( request_client_db_info( con, database_id, - voice_in_tx, - chat_tx, - activity_tx, - delta_tx, + channels, pending_moves, voice_activity, ) @@ -1392,10 +1375,7 @@ fn client_profile_refresh_plan( async fn request_messages( con: &mut Connection, command: OutCommand, - voice_in_tx: &mpsc::Sender, - chat_tx: &mpsc::Sender, - activity_tx: &mpsc::Sender, - delta_tx: &mpsc::Sender, + channels: &EventChannels, pending_moves: &mut PendingMoves, voice_activity: &mut HashMap, ) -> Result, ProtocolError> { @@ -1431,14 +1411,14 @@ async fn request_messages( return Ok(messages); } StreamItem::Audio(buf) => { - handle_audio_stream_item(voice_in_tx, voice_activity, buf).await; + handle_audio_stream_item(&channels.voice_in, voice_activity, buf).await; } other => handle_non_audio_stream_item( con, other, - chat_tx, - activity_tx, - delta_tx, + &channels.chat, + &channels.activity, + &channels.delta, pending_moves, ), } @@ -1448,20 +1428,14 @@ async fn request_messages( async fn request_client_db_info( con: &mut Connection, dbid: tsclientlib::ClientDbId, - voice_in_tx: &mpsc::Sender, - chat_tx: &mpsc::Sender, - activity_tx: &mpsc::Sender, - delta_tx: &mpsc::Sender, + channels: &EventChannels, pending_moves: &mut PendingMoves, voice_activity: &mut HashMap, ) -> Result { let messages = request_messages( con, build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]), - voice_in_tx, - chat_tx, - activity_tx, - delta_tx, + channels, pending_moves, voice_activity, ) @@ -1784,9 +1758,7 @@ fn format_server_activity(con: &Connection, ev: &tsclientlib::events::Event) -> extra, .. } => { - if extra.reason.is_none() { - return None; - } + extra.reason?; let client = activity_client(con, *client_id)?; let channel = activity_channel_name(con, client.channel)?; Some(format!( @@ -2164,7 +2136,7 @@ fn forward_delta( id: PropertyId::Client(client_id), .. } => { - if let Some(state) = con.get_state().ok() { + if let Ok(state) = con.get_state() { if let Some(client) = state.clients.get(client_id) { let _ = delta_tx.try_send(ProtocolDelta::ClientJoined { client_id: client_id.0 as u64, @@ -2181,15 +2153,13 @@ fn forward_delta( } Event::PropertyRemoved { id: PropertyId::Client(_), - old, + old: PropertyValue::Client(client), .. } => { - if let PropertyValue::Client(client) = old { - let _ = delta_tx.try_send(ProtocolDelta::ClientLeft { - client_id: client.id.0 as u64, - name: client.name.clone(), - }); - } + let _ = delta_tx.try_send(ProtocolDelta::ClientLeft { + client_id: client.id.0 as u64, + name: client.name.clone(), + }); } Event::PropertyChanged { id: PropertyId::ClientChannel(_), @@ -2199,7 +2169,7 @@ fn forward_delta( id: PropertyId::Client(client_id), .. } => { - if let Some(state) = con.get_state().ok() { + if let Ok(state) = con.get_state() { if let Some(client) = state.clients.get(client_id) { let _ = delta_tx.try_send(ProtocolDelta::ClientUpdated { client_id: client_id.0 as u64, @@ -2216,7 +2186,7 @@ fn forward_delta( id: PropertyId::Channel(channel_id), .. } => { - if let Some(state) = con.get_state().ok() { + if let Ok(state) = con.get_state() { if let Some(channel) = state.channels.get(channel_id) { let _ = delta_tx.try_send(ProtocolDelta::ChannelAdded { id: channel_id.0, @@ -2231,20 +2201,18 @@ fn forward_delta( } Event::PropertyRemoved { id: PropertyId::Channel(_), - old, + old: PropertyValue::Channel(channel), .. } => { - if let PropertyValue::Channel(channel) = old { - let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved { - id: channel.id.0, - }); - } + let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved { + id: channel.id.0, + }); } Event::PropertyChanged { id: PropertyId::Channel(channel_id), .. } => { - if let Some(state) = con.get_state().ok() { + if let Ok(state) = con.get_state() { if let Some(channel) = state.channels.get(channel_id) { let _ = delta_tx.try_send(ProtocolDelta::ChannelUpdated { id: channel_id.0,