diff --git a/Cargo.lock b/Cargo.lock index 38190da..eff8065 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -419,6 +419,7 @@ name = "chanora_audio" version = "0.2.0-beta.1" dependencies = [ "audiopus", + "bytemuck", "chanora_protocol", "coreaudio-rs", "cpal", diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index daaad93..946ba54 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -298,5 +298,6 @@ "clientVolumeMuteAction": "Mute user", "clientVolumeUnmuteAction": "Unmute user", "clientVolumeResetAction": "Reset to default", - "permissionDenied": "Permission Denied" + "permissionDenied": "Permission Denied", + "voiceTalkPowerBlocked": "Insufficient talk power to speak in this channel" } diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index d5bc8e7..40cfe96 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -241,5 +241,6 @@ "clientVolumeMuteAction": "静音该用户", "clientVolumeUnmuteAction": "取消静音", "clientVolumeResetAction": "恢复默认", - "permissionDenied": "权限被拒绝" + "permissionDenied": "权限被拒绝", + "voiceTalkPowerBlocked": "发言权限不足,无法在此频道发言" } diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index 5b27ffa..8618835 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -1246,6 +1246,12 @@ abstract class AppL10n { /// In en, this message translates to: /// **'Permission Denied'** String get permissionDenied; + + /// No description provided for @voiceTalkPowerBlocked. + /// + /// In en, this message translates to: + /// **'Insufficient talk power to speak in this channel'** + String get voiceTalkPowerBlocked; } class _AppL10nDelegate extends LocalizationsDelegate { diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index cdf4d65..10de84c 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -653,4 +653,8 @@ class AppL10nEn extends AppL10n { @override String get permissionDenied => 'Permission Denied'; + + @override + String get voiceTalkPowerBlocked => + 'Insufficient talk power to speak in this channel'; } diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index d90a2dd..204b418 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -641,4 +641,7 @@ class AppL10nZh extends AppL10n { @override String get permissionDenied => '权限被拒绝'; + + @override + String get voiceTalkPowerBlocked => '发言权限不足,无法在此频道发言'; } diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 52e159b..d895b24 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -385,8 +385,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { // and Notifications permission service. On non-macOS hosts the service // short-circuits to "granted" / "unsupported" and never wires the // MethodChannel. - final MacOSPermissionsService _macOSPermissions = - MacOSPermissionsService(); + final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService(); final UiPreferencesService _uiPreferences = const UiPreferencesService(); @override @@ -677,7 +676,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _reconnectDelay = null; }); case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs): - _recordUiDiagnostic('connection', 'reconnecting attempt=$attempt delay=${delaySecs}s'); + _recordUiDiagnostic( + 'connection', + 'reconnecting attempt=$attempt delay=${delaySecs}s', + ); setState(() { _phase = ConnectionPhase.reconnecting; _reconnectAttempt = attempt; @@ -852,23 +854,41 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { ); return updated; }); - case rust.BridgeEvent_ClientJoined(:final clientId, :final channelId, :final name, :final inputMuted, :final outputMuted, :final isServerQuery, :final talkPower, :final talkPowerGranted): + case rust.BridgeEvent_ClientJoined( + :final clientId, + :final channelId, + :final name, + :final inputMuted, + :final outputMuted, + :final isServerQuery, + :final talkPower, + :final talkPowerGranted, + ): if (!isServerQuery) { - _applyClientAdd(rust.BridgeClient( - id: clientId, - channel: channelId, - name: name, - inputMuted: inputMuted, - outputMuted: outputMuted, - isSpeaking: false, - isServerQuery: isServerQuery, - talkPower: talkPower, - talkPowerGranted: talkPowerGranted, - )); + _applyClientAdd( + rust.BridgeClient( + id: clientId, + channel: channelId, + name: name, + inputMuted: inputMuted, + outputMuted: outputMuted, + isSpeaking: false, + isServerQuery: isServerQuery, + talkPower: talkPower, + talkPowerGranted: talkPowerGranted, + ), + ); } case rust.BridgeEvent_ClientLeft(:final clientId): _applyClientRemove(clientId); - case rust.BridgeEvent_ClientUpdated(:final clientId, :final inputMuted, :final outputMuted, :final isServerQuery, :final talkPower, :final talkPowerGranted): + case rust.BridgeEvent_ClientUpdated( + :final clientId, + :final inputMuted, + :final outputMuted, + :final isServerQuery, + :final talkPower, + :final talkPowerGranted, + ): _applyClientDelta((c) => c.id == clientId, (c) { final updated = rust.BridgeClient( id: c.id, @@ -883,18 +903,32 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { ); return updated; }); - case rust.BridgeEvent_ChannelAdded(:final id, :final parent, :final name, :final order, :final hasPassword, :final neededTalkPower): - _applyChannelAdd(rust.BridgeChannel( - id: id, - parent: parent, - name: name, - order: order, - hasPassword: hasPassword, - neededTalkPower: neededTalkPower, - )); + case rust.BridgeEvent_ChannelAdded( + :final id, + :final parent, + :final name, + :final order, + :final hasPassword, + :final neededTalkPower, + ): + _applyChannelAdd( + rust.BridgeChannel( + id: id, + parent: parent, + name: name, + order: order, + hasPassword: hasPassword, + neededTalkPower: neededTalkPower, + ), + ); case rust.BridgeEvent_ChannelRemoved(:final id): _applyChannelRemove(id); - case rust.BridgeEvent_ChannelUpdated(:final id, :final name, :final hasPassword, :final neededTalkPower): + case rust.BridgeEvent_ChannelUpdated( + :final id, + :final name, + :final hasPassword, + :final neededTalkPower, + ): _applyChannelDelta((ch) => ch.id == id, (ch) { return rust.BridgeChannel( id: ch.id, @@ -932,7 +966,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { final now = DateTime.now(); if (_inChannel && (_lastSpeakingRefresh == null || - now.difference(_lastSpeakingRefresh!) >= _speakingRefreshInterval)) { + now.difference(_lastSpeakingRefresh!) >= + _speakingRefreshInterval)) { _lastSpeakingRefresh = now; unawaited(_refreshSnapshot(recordActivity: false, reportErrors: false)); } @@ -1134,14 +1169,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { Future _toggleOutputMute() async { final next = !_outputMuted; + setState(() { + _outputMuted = next; + }); try { await rust.setOutputMuted(muted: next); - if (!mounted) return; - setState(() { - _outputMuted = next; - }); } catch (e) { if (!mounted) return; + setState(() { + _outputMuted = !next; + }); _showUiError('output mute', e); } } @@ -1268,6 +1305,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { return; } final next = !_hardMute; + final previousInputMuted = _inputMuted; + final previousHardMute = _hardMute; + final previousPermissionMute = _hardMuteByPermission; + setState(() { + _inputMuted = next; + _hardMute = next; + if (next) { + _hardMuteByPermission = false; + } + }); try { // Hard-mute is two coordinated effects: // * setHardMute — local TransmitGate clamp; we stop sending @@ -1280,14 +1327,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { // through. Drive them together. await rust.setHardMute(muted: next); await rust.setInputMuted(muted: next); - if (!mounted) return; - setState(() { - _inputMuted = next; - _hardMute = next; - _hardMuteByPermission = false; - }); } catch (e) { if (!mounted) return; + setState(() { + _inputMuted = previousInputMuted; + _hardMute = previousHardMute; + _hardMuteByPermission = previousPermissionMute; + }); _showUiError('hard mute', e); } } @@ -1745,7 +1791,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { }; } - void _applyClientDelta(bool Function(rust.BridgeClient) test, rust.BridgeClient Function(rust.BridgeClient) update) { + void _applyClientDelta( + bool Function(rust.BridgeClient) test, + rust.BridgeClient Function(rust.BridgeClient) update, + ) { final snap = _snapshot; if (snap == null) return; final clients = snap.clients.map((c) => test(c) ? update(c) : c).toList(); @@ -1831,10 +1880,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { }); } - void _applyChannelDelta(bool Function(rust.BridgeChannel) test, rust.BridgeChannel Function(rust.BridgeChannel) update) { + void _applyChannelDelta( + bool Function(rust.BridgeChannel) test, + rust.BridgeChannel Function(rust.BridgeChannel) update, + ) { final snap = _snapshot; if (snap == null) return; - final channels = snap.channels.map((ch) => test(ch) ? update(ch) : ch).toList(); + final channels = snap.channels + .map((ch) => test(ch) ? update(ch) : ch) + .toList(); setState(() { _snapshot = rust.BridgeSnapshot( serverName: snap.serverName, @@ -2153,26 +2207,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { final theme = Theme.of(context); final headerActions = [ - if (_serverReachable && _inChannel) ...[ - IconButton( - tooltip: _hardMuteByTalkPower - ? 'Insufficient talk power to speak in this channel' - : l10n.voiceHardMuteLabel, - icon: Icon(_hardMute ? Icons.mic_off : Icons.mic), - isSelected: _hardMute, - selectedIcon: const Icon(Icons.mic_off), - color: _hardMute ? theme.colorScheme.error : null, - onPressed: _hardMuteByTalkPower ? null : _onToggleHardMute, - ), - IconButton( - tooltip: l10n.voiceOutputMuteLabel, - icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset), - isSelected: _outputMuted, - selectedIcon: const Icon(Icons.headset_off), - color: _outputMuted ? theme.colorScheme.error : null, - onPressed: _toggleOutputMute, - ), - ], IconButton( tooltip: l10n.aboutAction, icon: const Icon(Icons.info_outline), @@ -2466,10 +2500,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { isTouchOnly: isTouchOnlyPttHost, inputMuted: _hardMute, outputMuted: _outputMuted, + hardMuteByTalkPower: _hardMuteByTalkPower, talkPower: ownClientState?.talkPower, neededTalkPower: ownClientState?.neededTalkPower, talkPowerGranted: ownClientState?.talkPowerGranted, onTap: () => _onOpenVoiceDetailsSheet(), + onToggleInputMute: _onToggleHardMute, + onToggleOutputMute: _toggleOutputMute, ), if (_inChannel && _transmitMode == rust.BridgeTransmitMode.ptt) ...[ diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index e52c599..b0f25c6 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -53,8 +53,11 @@ class VoiceStatusChip extends StatelessWidget { required this.audioStats, required this.isTouchOnly, required this.onTap, + required this.onToggleInputMute, + required this.onToggleOutputMute, this.inputMuted = false, this.outputMuted = false, + this.hardMuteByTalkPower = false, this.talkPower, this.neededTalkPower, this.talkPowerGranted, @@ -81,6 +84,9 @@ class VoiceStatusChip extends StatelessWidget { /// True when local speaker is muted. final bool outputMuted; + /// True when the server talk-power gate forces local hard mute. + final bool hardMuteByTalkPower; + /// Own client's talk power. final int? talkPower; @@ -93,6 +99,12 @@ class VoiceStatusChip extends StatelessWidget { /// Open the voice details modal. final VoidCallback onTap; + /// Toggle local input hard mute. + final VoidCallback onToggleInputMute; + + /// Toggle local output mute/deafen. + final VoidCallback onToggleOutputMute; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -113,49 +125,48 @@ class VoiceStatusChip extends StatelessWidget { ); return Semantics( - button: true, label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}', - hint: l10n.voiceSettingsTitle, child: Material( type: MaterialType.transparency, - child: InkWell( - onTap: () { - HapticFeedback.lightImpact(); - onTap(); - }, - borderRadius: BorderRadius.circular(12), - child: ExcludeSemantics( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: summary.talkPowerBlocked - ? Colors.amber.withValues(alpha: 0.18) - : summary.muted - ? theme.colorScheme.errorContainer.withValues(alpha: 0.35) - : theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: summary.talkPowerBlocked - ? Colors.amber.shade700 - : summary.muted - ? theme.colorScheme.error - : theme.colorScheme.outlineVariant, - width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5, - ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: summary.talkPowerBlocked + ? Colors.amber.withValues(alpha: 0.18) + : summary.muted + ? theme.colorScheme.errorContainer.withValues(alpha: 0.35) + : theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: summary.talkPowerBlocked + ? Colors.amber.shade700 + : summary.muted + ? theme.colorScheme.error + : theme.colorScheme.outlineVariant, + width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5, + ), + ), + child: Row( + children: [ + Icon( + summary.micOn + ? Icons.fiber_manual_record + : Icons.fiber_manual_record_outlined, + size: 12, + color: summary.micOn + ? theme.colorScheme.primary + : theme.colorScheme.outline, ), - child: Row( - children: [ - Icon( - summary.micOn - ? Icons.fiber_manual_record - : Icons.fiber_manual_record_outlined, - size: 12, - color: summary.micOn - ? theme.colorScheme.primary - : theme.colorScheme.outline, - ), - const SizedBox(width: 8), - Expanded( + const SizedBox(width: 8), + Expanded( + child: InkWell( + onTap: () { + HapticFeedback.lightImpact(); + onTap(); + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -179,15 +190,60 @@ class VoiceStatusChip extends StatelessWidget { ], ), ), - const SizedBox(width: 8), - Icon( - Icons.expand_less, - size: 18, - color: theme.colorScheme.onSurfaceVariant, - ), - ], + ), ), - ), + const SizedBox(width: 4), + IconButton( + tooltip: hardMuteByTalkPower + ? l10n.voiceTalkPowerBlocked + : l10n.voiceHardMuteLabel, + icon: Icon(inputMuted ? Icons.mic_off : Icons.mic), + color: inputMuted ? theme.colorScheme.error : null, + onPressed: hardMuteByTalkPower ? null : onToggleInputMute, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor( + width: 40, + height: 40, + ), + padding: EdgeInsets.zero, + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + IconButton( + tooltip: l10n.voiceOutputMuteLabel, + icon: Icon(outputMuted ? Icons.headset_off : Icons.headset), + color: outputMuted ? theme.colorScheme.error : null, + onPressed: onToggleOutputMute, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor( + width: 40, + height: 40, + ), + padding: EdgeInsets.zero, + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + IconButton( + tooltip: l10n.voiceSettingsTitle, + icon: Icon( + Icons.expand_less, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + onPressed: onTap, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor( + width: 40, + height: 40, + ), + padding: EdgeInsets.zero, + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], ), ), ), diff --git a/crates/chanora_audio/Cargo.toml b/crates/chanora_audio/Cargo.toml index 6dc5d8b..174666d 100644 --- a/crates/chanora_audio/Cargo.toml +++ b/crates/chanora_audio/Cargo.toml @@ -73,6 +73,11 @@ ndk-context = "0.1" # - deduplicated macro impls # - PowerSavingOffloaded PerformanceMode variant oboe = { git = "https://github.com/EdisonJwa/oboe-rs", rev = "a14f9b83ecea8c93f5a692f2ee7808445b938c35" } +# Safe slice reinterpret for the oboe stereo output callback. +# bytemuck::cast_slice_mut replaces the raw-pointer cast from +# `&mut [(f32, f32)]` to `&mut [f32]` with a provenance-correct +# and UB-free transmute backed by `NoUninit`. +bytemuck = { version = "1", features = ["derive"] } [target.'cfg(target_os = "windows")'.dependencies] # Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices diff --git a/crates/chanora_audio/src/android_voice_unit.rs b/crates/chanora_audio/src/android_voice_unit.rs index 5d069e6..43bd976 100644 --- a/crates/chanora_audio/src/android_voice_unit.rs +++ b/crates/chanora_audio/src/android_voice_unit.rs @@ -542,19 +542,8 @@ impl AudioOutputCallback for OutputCallback { frames: &mut [(f32, f32)], ) -> DataCallbackResult { let _ = catch_unwind(AssertUnwindSafe(|| { - // SAFETY: `frames: &mut [(f32, f32)]` is an interleaved stereo - // buffer. `(f32, f32)` has the same size (8 bytes) and alignment - // (4 bytes) as `[f32; 2]`, so reinterpreting the slice as a flat - // `&mut [f32]` of length `frames.len() * 2` is sound. The Rust - // reference does not *guarantee* `#[repr(Rust)]` tuple layout, - // but (a) both fields are identical F32 primitives with no - // padding possible, and (b) the oboe crate uses - // `#[repr(transparent)]` on its frame type alias so the ABI - // contract is upheld at the FFI boundary. `frames` is not - // accessed again after `buf` is created, so no aliasing UB. - let buf: &mut [f32] = unsafe { - std::slice::from_raw_parts_mut(frames.as_mut_ptr() as *mut f32, frames.len() * 2) - }; + let buf: &mut [f32] = + bytemuck::cast_slice_mut::<(f32, f32), f32>(frames); for s in buf.iter_mut() { *s = 0.0; }