Compare commits

...
Author SHA1 Message Date
Edison Jwa e0060f3c19 feat(voice): unified mobile voice bar with gesture-isolated PTT row
Replace separate VoiceStatusChip + VoicePttButton with a single
CompactVoiceBar widget that combines both into a two-row layout:

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

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

Key changes:
- Add CompactVoiceBar widget with state-colored container (normal,
  muted, talk-power-blocked)
- Remove mute/deafen IconButtons from AppBar headerActions
- Restructure voice details sheet into primary section + collapsible
  ExpansionTiles (audio processing, PTT capability, debug)
- Optimistic state updates for mute/deafen to eliminate tap delay
- Instant PTT visual feedback (no AnimatedContainer fade)
- Constant geometry across all states (no layout shift on toggle)
2026-06-04 22:46:19 +09:00
Edison Jwa 8cb5a7a258 feat(macos): add macOS permissions service for Input Monitoring, Local Network, and Notifications
Add MacOSPermissionsService (Dart) + native MethodChannel handler (Swift)
for macOS-specific permissions not covered by permission_handler:

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

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

Changes:
- Info.plist: add NSBonjourServices array with _ts3._tcp
- macos_permissions_service.dart: Dart service with MethodChannel,
  ValueNotifier states, PTT capability derivation (L0Focused /
  L1MacOSEventTap), non-macOS short-circuit
- MainFlutterWindow.swift: native handler registered as FlutterPlugin,
  Input Monitoring check/request/polling, NWBrowser trigger with
  denial detection, UNUserNotificationCenter request
- main.dart: wire service into bootstrap lifecycle, listen for PTT
  capability changes from Input Monitoring state
- macos_permissions_service_test.dart: 17 unit tests covering inbound
  state changes, outbound calls, lifecycle, error handling, platform
  behavior (179/179 full suite pass)
2026-06-04 08:21:07 +09:00
Edison Jwa 484dad1072 fix(audio): eliminate Android output stutter via Oboe config + lock-free callback
Phase 1 — Oboe configuration:
- Change output stream from Usage::VoiceCommunication to Usage::Game with
  ContentType::Sonification to avoid forcing the Legacy (OpenSL ES) data
  path on most devices (Oboe issue #2075)
- Switch output format from i16 Mono to f32 Stereo, matching Qint's proven
  configuration and eliminating per-callback downmix conversion
- Set buffer size to 2x burst after stream open, reducing default buffer
  from 8-20x burst to 2x burst for lower latency
- Remove scratch Mutex<Vec<f32>>; callback writes directly to Oboe buffer

Phase 2 — Lock-free output callback:
- Add audio_event_queue.rs: lock-free SPSC bridge using crossbeam ArrayQueue
  with separate packet (lossy) and control (reliable) channels
- OutputCallback now owns AudioHandler directly (no Arc<Mutex<>> on Android)
- Inbound forwarder pushes packets via AudioEventProducer (no mutex)
- set_client_volume pushes control commands via event queue on Android
- iOS/desktop Arc<Mutex<AudioHandler>> path unchanged
2026-06-04 00:51:25 +09:00
13 changed files with 2299 additions and 299 deletions
Generated
+22
View File
@@ -423,6 +423,8 @@ dependencies = [
"coreaudio-rs", "coreaudio-rs",
"cpal", "cpal",
"criterion", "criterion",
"crossbeam",
"crossbeam-utils",
"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"
+68 -39
View File
@@ -23,6 +23,7 @@ import 'services/audio_lifecycle_service.dart';
import 'services/channel_join_error_mapper.dart'; import 'services/channel_join_error_mapper.dart';
import 'services/connection_phase_state.dart'; import 'services/connection_phase_state.dart';
import 'services/ios_permissions_service.dart'; import 'services/ios_permissions_service.dart';
import 'services/macos_permissions_service.dart';
import 'services/prefetch_debouncer.dart'; import 'services/prefetch_debouncer.dart';
import 'services/snapshot_state_mapper.dart'; import 'services/snapshot_state_mapper.dart';
import 'services/ts3_server_link.dart'; import 'services/ts3_server_link.dart';
@@ -378,6 +379,12 @@ 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
@@ -399,6 +406,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.checkInitialStates();
_macOSPermissions.pttCapabilityState.addListener(
_onMacOSPttCapabilityChanged,
);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_requestRecordAudioOnStartup()); unawaited(_requestRecordAudioOnStartup());
}); });
@@ -498,6 +515,26 @@ 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';
}
});
}
}
Future<void> _clearPermissionHardMute() async { Future<void> _clearPermissionHardMute() async {
if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return; if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return;
_permissionHardMuteClearInFlight = true; _permissionHardMuteClearInFlight = true;
@@ -936,11 +973,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 +1126,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
Future<void> _toggleOutputMute() async { Future<void> _toggleOutputMute() async {
final next = !_outputMuted; final next = !_outputMuted;
try { // Optimistic: flip the UI immediately.
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;
// Roll back on failure.
setState(() {
_outputMuted = !next;
});
_showUiError('output mute', e); _showUiError('output mute', e);
} }
} }
@@ -1218,6 +1264,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
return; return;
} }
final next = !_hardMute; final next = !_hardMute;
// Optimistic: flip the UI immediately so the icon responds
// before the two bridge calls round-trip through FFI.
setState(() {
_inputMuted = next;
_hardMute = 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 +1283,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;
// Roll back on failure.
setState(() {
_inputMuted = !next;
_hardMute = !next;
});
_showUiError('hard mute', e); _showUiError('hard mute', e);
} }
} }
@@ -2103,26 +2155,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),
@@ -2407,7 +2439,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
Expanded(child: snapshotView), Expanded(child: snapshotView),
const SizedBox(height: 8), const SizedBox(height: 8),
permissionBanner, permissionBanner,
VoiceStatusChip( CompactVoiceBar(
inChannel: _inChannel,
transmitMode: _transmitMode, transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs, releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel, pttBoundKeyLabel: _pttBoundKeyLabel,
@@ -2415,19 +2448,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
isTouchOnly: isTouchOnlyPttHost, isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute, inputMuted: _hardMute,
outputMuted: _outputMuted, outputMuted: _outputMuted,
onToggleInputMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
onOpenDetails: () => _onOpenVoiceDetailsSheet(),
onPttHeldChanged: _onOnscreenPttHeldChanged,
talkPower: ownClientState?.talkPower, talkPower: ownClientState?.talkPower,
neededTalkPower: ownClientState?.neededTalkPower, neededTalkPower: ownClientState?.neededTalkPower,
talkPowerGranted: ownClientState?.talkPowerGranted, talkPowerGranted: ownClientState?.talkPowerGranted,
onTap: () => _onOpenVoiceDetailsSheet(), hardMuteByTalkPower: _hardMuteByTalkPower,
), ),
if (_inChannel &&
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
const SizedBox(height: 8),
VoicePttButton(
active: _audioStats?.pttActive ?? false,
onHeldChanged: _onOnscreenPttHeldChanged,
),
],
], ],
); );
}, },
@@ -0,0 +1,498 @@
/// 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);
}
case methodLocalNetworkStateChanged:
final args = call.arguments;
if (args is Map) {
_localNetworkState.value =
_parseLocalNetworkState(args['state'] as String?);
}
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();
}
}
@@ -1,11 +1,15 @@
// Compact voice UI for narrow / mobile layouts (Plan E hybrid: // Compact voice UI for narrow / mobile layouts.
// AppBar mutes + status chip with 2-line live readout + wide bottom-
// anchored PTT button + modal sheet for non-essential controls).
// //
// rc.8 follow-up: the AppBar gear icon was removed; the modal sheet // Two-zone voice bar pinned to the bottom:
// is now the **single** voice-controls surface on mobile. Mode + // • Control row: status text + mute + deafen + settings chevron
// release-tail are surfaced inline (radio buttons + slider) inside // • PTT row: full-width hold-to-talk (PTT mode only)
// the modal. // Both share a single container whose background colour reflects
// the current voice state (normal / muted / talk-power-blocked).
//
// Gesture isolation: the control row uses tap-only InkWell /
// IconButton; the PTT row uses a raw Listener for pointer-down /
// pointer-up. Because each row is a disjoint hit-test region, a
// finger holding PTT cannot accidentally toggle mute or deafen.
import 'dart:async' show Timer, unawaited; import 'dart:async' show Timer, unawaited;
import 'dart:io' show Platform; import 'dart:io' show Platform;
@@ -539,6 +543,8 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge), Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge),
const SizedBox(height: 12), const SizedBox(height: 12),
// ── Primary section (always visible) ────────────────────
// 1) Audio output route picker tile (mobile only). // 1) Audio output route picker tile (mobile only).
if (showRoutePicker) ...[ if (showRoutePicker) ...[
const AudioOutputTile(), const AudioOutputTile(),
@@ -574,7 +580,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity), onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
), ),
// 3) Release-tail slider (PTT only). // Release-tail slider (PTT only).
if (isPtt) ...[ if (isPtt) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
@@ -624,7 +630,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
Divider(height: 1, color: theme.colorScheme.outlineVariant), Divider(height: 1, color: theme.colorScheme.outlineVariant),
const SizedBox(height: 12), const SizedBox(height: 12),
// 4) Level meter + live TX/RX stats. // Level meter + live TX/RX stats.
VoiceLevelMeter(active: levelActive), VoiceLevelMeter(active: levelActive),
const SizedBox(height: 6), const SizedBox(height: 6),
_StatsRow( _StatsRow(
@@ -648,18 +654,76 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
), ),
], ],
// Audio processing.
const SizedBox(height: 12),
Divider(height: 1, color: theme.colorScheme.outlineVariant),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(
// ── Collapsible: Audio processing ───────────────────────
ExpansionTile(
initiallyExpanded: false,
shape: const Border(),
collapsedShape: const Border(),
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
title: Text(
'Audio processing', 'Audio processing',
style: theme.textTheme.labelLarge?.copyWith( style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 4), children: [
_buildAudioProcessingSection(theme),
],
),
// ── Collapsible: PTT capability (PTT mode only) ─────────
if (isPtt)
ExpansionTile(
initiallyExpanded: false,
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
shape: const Border(),
collapsedShape: const Border(),
title: Text(
'PTT capability',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
children: [
PttCapabilityBadge(
level: widget.pttLevel,
backendId: widget.pttBackendId,
boundInputClass: widget.pttBoundInputClass,
),
const SizedBox(height: 8),
],
),
// ── Collapsible: Debug ──────────────────────────────────
ExpansionTile(
initiallyExpanded: false,
shape: const Border(),
collapsedShape: const Border(),
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
title: Text(
'Debug',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
children: [
_buildDebugSection(theme),
],
),
],
),
),
);
}
// ── Audio processing section (inside ExpansionTile) ─────────────────
Widget _buildAudioProcessingSection(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Android HW/SW selector. // Android HW/SW selector.
if (Platform.isAndroid) ...[ if (Platform.isAndroid) ...[
const VoiceSubHeader('Processing backend'), const VoiceSubHeader('Processing backend'),
@@ -786,16 +850,17 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
_notifyAudioConfig(); _notifyAudioConfig();
}, },
), ),
// Debug.
const SizedBox(height: 8), const SizedBox(height: 8),
Text( ],
'Debug', );
style: theme.textTheme.labelLarge?.copyWith( }
color: theme.colorScheme.onSurfaceVariant,
), // ── Debug section (inside ExpansionTile) ─────────────────────────────
),
const SizedBox(height: 2), Widget _buildDebugSection(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AudioProcessingToggleRow( AudioProcessingToggleRow(
dense: true, dense: true,
label: 'WAV dump', label: 'WAV dump',
@@ -806,23 +871,8 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
_notifyAudioConfig(); _notifyAudioConfig();
}, },
), ),
const SizedBox(height: 8),
// 6) PTT capability badge. On iOS this must remain
// visible even though the resolved level is always
// `L0Focused`, because the P0 acceptance flow requires
// honest capability advertising with an explanation of
// the sandbox limitation.
if (isPtt) ...[
const SizedBox(height: 12),
PttCapabilityBadge(
level: widget.pttLevel,
backendId: widget.pttBackendId,
boundInputClass: widget.pttBoundInputClass,
),
], ],
],
),
),
); );
} }
} }
@@ -948,3 +998,424 @@ class _ModeRow extends StatelessWidget {
); );
} }
} }
// ── Unified mobile voice bar ──────────────────────────────────────────────
/// A unified bottom-anchored voice bar for compact / mobile layouts.
///
/// Combines the former [VoiceStatusChip] and [VoicePttButton] into one
/// visual zone with two rows:
///
/// ┌──────────────────────────────────────────────┐
/// │ 🟢 PTT · Connected [🔇] [🎧] [▲] │ ← control row (tap)
/// ├──────────────────────────────────────────────┤
/// │ ════ hold to talk ════ │ ← PTT row (hold)
/// └──────────────────────────────────────────────┘
///
/// The PTT row is shown only when [transmitMode] is PTT; for continuous
/// or voice-activity modes the bar shrinks to the control row alone.
///
/// State colour is applied to the entire container:
/// - normal: `surfaceContainerHigh`
/// - muted: `errorContainer` (35 % alpha)
/// - talk-power-block: amber (18 % alpha)
class CompactVoiceBar extends StatelessWidget {
/// Construct a compact voice bar.
const CompactVoiceBar({
super.key,
required this.inChannel,
required this.transmitMode,
required this.releaseTailMs,
required this.pttBoundKeyLabel,
required this.audioStats,
required this.isTouchOnly,
required this.inputMuted,
required this.outputMuted,
required this.onToggleInputMute,
required this.onToggleOutputMute,
required this.onOpenDetails,
required this.onPttHeldChanged,
this.talkPower,
this.neededTalkPower,
this.talkPowerGranted,
this.hardMuteByTalkPower = false,
});
/// True when the client is inside a channel (gates PTT row visibility).
final bool inChannel;
/// Current transmit mode.
final rust.BridgeTransmitMode transmitMode;
/// Release-tail in milliseconds.
final int releaseTailMs;
/// Bound key label (empty on touch-only hosts).
final String pttBoundKeyLabel;
/// Current audio stats; null while audio engine not running.
final rust.BridgeAudioStats? audioStats;
/// True on iOS / iPadOS / Android.
final bool isTouchOnly;
/// True when local mic is muted.
final bool inputMuted;
/// True when local speaker is muted.
final bool outputMuted;
/// Toggle hard-mute on / off.
final VoidCallback onToggleInputMute;
/// Toggle output mute on / off.
final VoidCallback onToggleOutputMute;
/// Open the voice details modal sheet.
final VoidCallback onOpenDetails;
/// Called with `true` on finger-down, `false` on finger-up / cancel.
final ValueChanged<bool> onPttHeldChanged;
/// Own client's talk power.
final int? talkPower;
/// Talk power required to speak in current channel.
final int? neededTalkPower;
/// True when server granted talk power.
final bool? talkPowerGranted;
/// True when talk power prevents speaking.
final bool hardMuteByTalkPower;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
final pttActive = audioStats?.pttActive ?? false;
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: transmitMode,
releaseTailMs: releaseTailMs,
pttBoundKeyLabel: pttBoundKeyLabel,
isTouchOnly: isTouchOnly,
inputMuted: inputMuted,
outputMuted: outputMuted,
pttActive: pttActive,
talkPower: talkPower,
neededTalkPower: neededTalkPower,
talkPowerGranted: talkPowerGranted,
);
// Container colour based on voice state.
final containerColor = summary.talkPowerBlocked
? Colors.amber.withValues(alpha: 0.18)
: summary.muted
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
: theme.colorScheme.surfaceContainerHigh;
final borderColor = summary.talkPowerBlocked
? Colors.amber.shade700
: summary.muted
? theme.colorScheme.error
: theme.colorScheme.outlineVariant;
final borderWidth = 1.0;
return Semantics(
label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}',
child: Material(
type: MaterialType.transparency,
child: Container(
decoration: BoxDecoration(
color: containerColor,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: borderColor, width: borderWidth),
),
clipBehavior: Clip.antiAlias,
child: AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Control row ──────────────────────────────────────
_ControlRow(
summary: summary,
inputMuted: inputMuted,
outputMuted: outputMuted,
hardMuteByTalkPower: hardMuteByTalkPower,
onToggleInputMute: onToggleInputMute,
onToggleOutputMute: onToggleOutputMute,
onOpenDetails: onOpenDetails,
),
// ── PTT row (in-channel + PTT mode only) ──────────────
if (isPtt && inChannel) ...[
Divider(
height: 1,
thickness: 0.5,
color: borderColor,
indent: 12,
endIndent: 12,
),
_PttRow(
active: pttActive,
enabled: !hardMuteByTalkPower,
onHeldChanged: onPttHeldChanged,
),
],
],
),
),
),
),
);
}
}
// ── Control row (tap-only zone) ───────────────────────────────────────────
class _ControlRow extends StatelessWidget {
const _ControlRow({
required this.summary,
required this.inputMuted,
required this.outputMuted,
required this.hardMuteByTalkPower,
required this.onToggleInputMute,
required this.onToggleOutputMute,
required this.onOpenDetails,
});
final VoiceStatusSummary summary;
final bool inputMuted;
final bool outputMuted;
final bool hardMuteByTalkPower;
final VoidCallback onToggleInputMute;
final VoidCallback onToggleOutputMute;
final VoidCallback onOpenDetails;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Status dot.
Icon(
summary.micOn ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined,
size: 10,
color: summary.micOn ? theme.colorScheme.primary : theme.colorScheme.outline,
),
const SizedBox(width: 8),
// Status text (tappable → open details).
Expanded(
child: Semantics(
button: true,
label: summary.line1,
child: InkWell(
onTap: onOpenDetails,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
summary.line1,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
summary.line2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
),
const SizedBox(width: 4),
// Mute button.
_ToggleButton(
icon: inputMuted ? Icons.mic_off : Icons.mic,
isActive: inputMuted,
tooltip: 'Mute mic',
onPressed: hardMuteByTalkPower ? null : onToggleInputMute,
),
// Deafen button.
_ToggleButton(
icon: outputMuted ? Icons.headset_off : Icons.headset,
isActive: outputMuted,
tooltip: 'Deafen',
onPressed: onToggleOutputMute,
),
// Settings / expand chevron.
IconButton(
icon: Icon(Icons.expand_less, size: 20, color: theme.colorScheme.onSurfaceVariant),
tooltip: 'Voice settings',
onPressed: onOpenDetails,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
padding: EdgeInsets.zero,
),
],
),
);
}
}
// ── Toggle button (mute / deafen) ─────────────────────────────────────────
class _ToggleButton extends StatelessWidget {
const _ToggleButton({
required this.icon,
required this.isActive,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final bool isActive;
final String tooltip;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return IconButton(
icon: Icon(icon, size: 22),
tooltip: tooltip,
color: isActive ? theme.colorScheme.error : null,
onPressed: onPressed,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
padding: EdgeInsets.zero,
);
}
}
// ── PTT row (hold-only zone) ──────────────────────────────────────────────
class _PttRow extends StatefulWidget {
const _PttRow({
required this.active,
required this.enabled,
required this.onHeldChanged,
});
/// True while the engine reports the gate open.
final bool active;
/// Whether the PTT button can be engaged.
final bool enabled;
/// Called with `true` on pointer-down, `false` on pointer-up / cancel.
final ValueChanged<bool> onHeldChanged;
@override
State<_PttRow> createState() => _PttRowState();
}
class _PttRowState extends State<_PttRow> {
int? _activePointer;
bool _held = false;
@override
void initState() {
super.initState();
prepareVoiceHaptics();
}
void _begin(PointerDownEvent event) {
if (!widget.enabled || _activePointer != null) return;
_activePointer = event.pointer;
_held = true;
widget.onHeldChanged(true);
playVoicePttHaptic(true);
setState(() {});
}
void _end(int pointer) {
if (_activePointer != pointer) return;
_activePointer = null;
_held = false;
widget.onHeldChanged(false);
playVoicePttHaptic(false);
setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
final activeNow = _held || widget.active;
return Semantics(
button: true,
liveRegion: true,
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
hint: l10n.pttHoldToTalkSemanticsHint,
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: _begin,
onPointerUp: (e) => _end(e.pointer),
onPointerCancel: (e) => _end(e.pointer),
child: ExcludeSemantics(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: activeNow
? theme.colorScheme.primary
: Colors.transparent,
),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
activeNow ? Icons.mic : Icons.mic_none_outlined,
size: 22,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
),
);
}
}
@@ -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,291 @@
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())
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 +301,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()
} }
} }
@@ -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();
},
);
}
+2
View File
@@ -29,6 +29,8 @@ 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"] }
crossbeam-utils = { version = "0.8", default-features = false }
[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.
+84 -53
View File
@@ -44,6 +44,7 @@ use std::sync::{Arc, Mutex};
use audiopus::coder::Encoder as OpusEncoder; use audiopus::coder::Encoder as OpusEncoder;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::audio_event_queue::{AudioCommand, AudioEventQueue};
use crate::mobile_voice_backend::{ use crate::mobile_voice_backend::{
clear_android_audio_diagnostics, latency_tier_for, next_input_preset_after, clear_android_audio_diagnostics, latency_tier_for, next_input_preset_after,
next_sharing_mode_after, publish_android_audio_diagnostics, AchievedInputPreset, next_sharing_mode_after, publish_android_audio_diagnostics, AchievedInputPreset,
@@ -63,7 +64,7 @@ use oboe::{
AudioInputCallback, AudioInputStreamSafe, AudioOutputCallback, AudioOutputStreamSafe, AudioInputCallback, AudioInputStreamSafe, AudioOutputCallback, AudioOutputStreamSafe,
AudioStream, AudioStreamAsync, AudioStreamBase, AudioStreamBuilder, AudioStreamSafe, AudioStream, AudioStreamAsync, AudioStreamBase, AudioStreamBuilder, AudioStreamSafe,
DataCallbackResult, Input as OboeInput, InputPreset, Mono, Output as OboeOutput, DataCallbackResult, Input as OboeInput, InputPreset, Mono, Output as OboeOutput,
PerformanceMode, SessionId, SharingMode, Usage, PerformanceMode, SessionId, SharingMode, Stereo, Usage,
}; };
use crate::processor::AudioProcessor; use crate::processor::AudioProcessor;
@@ -518,14 +519,14 @@ impl AudioInputCallback for InputCallback {
// //
// Mirrors the iOS VPIO render callback. Pulls mixed 48 kHz stereo f32 // Mirrors the iOS VPIO render callback. Pulls mixed 48 kHz stereo f32
// from `AudioHandler::fill_buffer`, applies output gain + mute, and // from `AudioHandler::fill_buffer`, applies output gain + mute, and
// writes mono i16 to the Oboe output buffer. // writes stereo f32 directly to the Oboe output buffer.
struct OutputCallback { struct OutputCallback {
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>, handler: AudioHandler<SessionAudioId>,
event_queue: Arc<AudioEventQueue>,
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,56 @@ 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] = unsafe {
let scratch = &mut self.scratch.lock().unwrap(); std::slice::from_raw_parts_mut(frames.as_mut_ptr() as *mut f32, frames.len() * 2)
if scratch.len() < needed { };
scratch.resize(needed, 0.0); for s in buf.iter_mut() {
} else {
for s in &mut scratch[..needed] {
*s = 0.0; *s = 0.0;
} }
} let consumer = AudioEventQueue::consumer(&self.event_queue);
match self.handler.try_lock() { for cmd in consumer.drain_controls() {
Ok(mut h) => { match cmd {
let _ = h.fill_buffer(&mut scratch[..needed]); AudioCommand::SetVolume(id, vol) => {
} if let Some(q) = self.handler.get_mut_queues().get_mut(&id) {
Err(std::sync::TryLockError::WouldBlock) => {} q.volume = vol;
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 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 +683,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 +770,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 +782,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_queue: event_queue.clone(),
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 +815,41 @@ impl AndroidVoiceUnit {
Self::open_output_fallback( Self::open_output_fallback(
cfg, cfg,
&event_tx, &event_tx,
params.handler.clone(), AudioHandler::new(),
event_queue.clone(),
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 +1068,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_queue: Arc<AudioEventQueue>,
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_queue,
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 +1089,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,118 @@
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.
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())
}
}
+40 -21
View File
@@ -52,6 +52,8 @@ use tsclientlib::audio::AudioHandler;
use chanora_protocol::{InboundVoice, OutPacket}; use chanora_protocol::{InboundVoice, OutPacket};
#[cfg(target_os = "android")]
use crate::audio_event_queue::{AudioCommand, AudioEventQueue, AudioPacket};
use crate::AudioError; use crate::AudioError;
#[cfg(all( #[cfg(all(
@@ -305,12 +307,15 @@ pub struct AudioEngine {
output_muted: Arc<AtomicBool>, output_muted: Arc<AtomicBool>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>, audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>, audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
#[cfg(not(target_os = "android"))]
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>, audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] #[cfg(target_os = "android")]
audio_event_producer: crate::audio_event_queue::AudioEventProducer,
#[cfg(target_os = "android")]
voice_out_tx: mpsc::Sender<OutPacket>, voice_out_tx: mpsc::Sender<OutPacket>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] #[cfg(target_os = "android")]
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>, voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] #[cfg(target_os = "android")]
mic_gain: f32, mic_gain: f32,
// Streams must be dropped to stop audio. Both are `!Send` because // Streams must be dropped to stop audio. Both are `!Send` because
// cpal's Stream isn't Send on some backends; we keep them in an // cpal's Stream isn't Send on some backends; we keep them in an
@@ -482,7 +487,7 @@ impl AudioEngine {
voice_out_tx: mpsc::Sender<OutPacket>, voice_out_tx: mpsc::Sender<OutPacket>,
transmit_gate: crate::ptt::AudioTransmitGate, transmit_gate: crate::ptt::AudioTransmitGate,
frames_sent: Arc<AtomicU32>, frames_sent: Arc<AtomicU32>,
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>, event_producer: crate::audio_event_queue::AudioEventProducer,
output_gain: Arc<AtomicU32>, output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>, output_muted: Arc<AtomicBool>,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>, voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
@@ -552,7 +557,8 @@ impl AudioEngine {
transmit_active: transmit_gate.flag_arc(), transmit_active: transmit_gate.flag_arc(),
frames_sent: frames_sent.clone(), frames_sent: frames_sent.clone(),
mic_gain, mic_gain,
handler: audio_handler.clone(), handler: AudioHandler::new(),
event_producer: event_producer.clone(),
output_gain: output_gain.clone(), output_gain: output_gain.clone(),
output_muted: output_muted.clone(), output_muted: output_muted.clone(),
voice_activity_selector: voice_activity_selector.clone(), voice_activity_selector: voice_activity_selector.clone(),
@@ -572,7 +578,7 @@ impl AudioEngine {
voice_out_tx.clone(), voice_out_tx.clone(),
transmit_gate.clone(), transmit_gate.clone(),
frames_sent.clone(), frames_sent.clone(),
audio_handler.clone(), event_producer.clone(),
output_gain.clone(), output_gain.clone(),
output_muted.clone(), output_muted.clone(),
voice_activity_selector.clone(), voice_activity_selector.clone(),
@@ -1007,8 +1013,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 +1075,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 +1110,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 +1158,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 +1171,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 +1191,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 +1313,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 +1472,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 +1494,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(),
@@ -1658,6 +1661,21 @@ 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);
loop {
match self.audio_event_producer.push_control(cmd) {
Ok(()) => break,
Err(returned) => {
cmd = returned;
std::thread::yield_now();
}
}
}
}
#[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)) {
@@ -1675,6 +1693,7 @@ impl AudioEngine {
} }
} }
} }
}
} }
impl Drop for AudioEngine { impl Drop for AudioEngine {
+2
View File
@@ -28,6 +28,8 @@
#![warn(missing_docs)] #![warn(missing_docs)]
#[cfg(target_os = "android")]
mod audio_event_queue;
pub mod audio_processing; pub mod audio_processing;
pub mod debug_wav; pub mod debug_wav;
mod engine; mod engine;
@@ -22,6 +22,9 @@ use std::sync::atomic::{AtomicBool, AtomicU32};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::sync::mpsc; use tokio::sync::mpsc;
#[cfg(not(target_os = "android"))]
use tsclientlib::audio::AudioHandler;
#[cfg(target_os = "android")]
use tsclientlib::audio::AudioHandler; use tsclientlib::audio::AudioHandler;
use crate::engine::SessionAudioId; use crate::engine::SessionAudioId;
@@ -67,7 +70,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 +81,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).