diff --git a/app/lib/desktop/pages/conversations/widgets/desktop_recording_widget.dart b/app/lib/desktop/pages/conversations/widgets/desktop_recording_widget.dart index ed4442ba594..f7b2d42ceef 100644 --- a/app/lib/desktop/pages/conversations/widgets/desktop_recording_widget.dart +++ b/app/lib/desktop/pages/conversations/widgets/desktop_recording_widget.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:omi/backend/schema/transcript_segment.dart'; import 'package:omi/providers/capture_provider.dart'; import 'package:omi/providers/connectivity_provider.dart'; @@ -12,6 +13,7 @@ import 'package:omi/utils/platform/platform_service.dart'; import 'package:omi/utils/responsive/responsive_helper.dart'; import 'package:provider/provider.dart'; import 'package:omi/ui/atoms/omi_icon_button.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class DesktopRecordingWidget extends StatefulWidget { final VoidCallback? onBack; @@ -33,6 +35,32 @@ class DesktopRecordingWidget extends StatefulWidget { class _DesktopRecordingWidgetState extends State { bool _isHovered = false; + List> _availableAudioDevices = []; + String? _selectedDeviceId; + final GlobalKey _micKey = GlobalKey(); + OverlayEntry? _audioDeviceOverlayEntry; + + @override + void initState() { + super.initState(); + _loadSavedDeviceId(); + } + + Future _loadSavedDeviceId() async { + final prefs = await SharedPreferences.getInstance(); + final savedDeviceId = prefs.getString('selected_audio_device_id'); + if (savedDeviceId == null || savedDeviceId.isEmpty) return; + + _setSelectedDeviceId(savedDeviceId); + } + + void _setSelectedDeviceId(String deviceId) { + if (mounted) { + setState(() { + _selectedDeviceId = deviceId; + }); + } + } Future _toggleRecording(BuildContext context, CaptureProvider provider) async { var recordingState = provider.recordingState; @@ -858,53 +886,292 @@ class _DesktopRecordingWidgetState extends State { ); } - Widget _buildAudioSourceStatus(CaptureProvider captureProvider) { - final micName = captureProvider.microphoneName; - final micLevel = captureProvider.microphoneLevel; - final systemLevel = captureProvider.systemAudioLevel; +Widget _buildAudioSourceStatus(CaptureProvider captureProvider) { + final micName = captureProvider.microphoneName; + final micLevel = captureProvider.microphoneLevel; + final systemLevel = captureProvider.systemAudioLevel; + + if (micName == null) { + return const SizedBox.shrink(); + } + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _buildMicrophoneSection(micName, micLevel), + const SizedBox(width: 24), + Row( + children: [ + const Icon(Icons.volume_up_rounded, size: 16, color: ResponsiveHelper.textSecondary), + const SizedBox(width: 8), + const Text( + 'System', + style: TextStyle(fontSize: 13, color: ResponsiveHelper.textSecondary), + ), + const SizedBox(width: 8), + _buildAudioLevelBar(systemLevel, Colors.orange.shade600), + ], + ), + ], + ); +} + +Widget _buildMicrophoneSection(String micName, double micLevel) { + return GestureDetector( + key: _micKey, + onTap: _toggleAudioDeviceDropdown, + child: Tooltip( + message: micName, + child: Row( + children: [ + const Icon(Icons.mic_rounded, size: 16, color: ResponsiveHelper.textSecondary), + const SizedBox(width: 8), + const Text( + 'Mic', + style: TextStyle(fontSize: 13, color: ResponsiveHelper.textSecondary), + ), + const SizedBox(width: 4), + Icon( + _audioDeviceOverlayEntry != null ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, + size: 14, + color: ResponsiveHelper.textSecondary, + ), + const SizedBox(width: 8), + _buildAudioLevelBar(micLevel, ResponsiveHelper.purplePrimary), + ], + ), + ), + ); +} - if (micName == null) { - return const SizedBox.shrink(); +void _toggleAudioDeviceDropdown() { + if (_audioDeviceOverlayEntry != null) { + _removeAudioDeviceOverlay(); + } else { + if (_availableAudioDevices.isEmpty) { + _loadAvailableAudioDevices(); } + _audioDeviceOverlayEntry = _createAudioDeviceOverlayEntry(); + Overlay.of(context).insert(_audioDeviceOverlayEntry!); + setState(() {}); + } +} - return Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // Microphone - Tooltip( - message: micName ?? 'Default Microphone', - child: Row( - children: [ - const Icon(Icons.mic_rounded, size: 16, color: ResponsiveHelper.textSecondary), - const SizedBox(width: 8), - const Text( - 'Mic', - style: TextStyle(fontSize: 13, color: ResponsiveHelper.textSecondary), - ), - const SizedBox(width: 8), - _buildAudioLevelBar(micLevel, ResponsiveHelper.purplePrimary), - ], +void _removeAudioDeviceOverlay() { + _audioDeviceOverlayEntry?.remove(); + _audioDeviceOverlayEntry = null; + setState(() {}); +} + +OverlayEntry _createAudioDeviceOverlayEntry() { + final renderBox = _micKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) { + return OverlayEntry(builder: (context) => const SizedBox.shrink()); + } + + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + + return OverlayEntry( + builder: (context) => GestureDetector( + onTap: _removeAudioDeviceOverlay, + behavior: HitTestBehavior.translucent, + child: Stack( + children: [ + _overlayBackground(), + Positioned( + top: offset.dy + size.height + 5, + left: offset.dx - 100 + (size.width / 2), + child: _overlayDropdown(), ), + ], + ), + ), + ); +} + +Widget _overlayBackground() => Positioned.fill( + child: Container(color: Colors.transparent), +); + +Widget _overlayDropdown() => GestureDetector( + onTap: () {}, + child: _buildFloatingAudioDeviceDropdown(), +); + +Future _selectAudioDevice(Map device) async { + setState(() { + _selectedDeviceId = device['id']; + }); + + _removeAudioDeviceOverlay(); + + SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString('selected_audio_device_id', device['id'] ?? ''); + + try { + final result = await const MethodChannel('screenCapturePlatform') + .invokeMethod('selectAudioDevice', {'deviceId': device['id']}); + + if (mounted && result == true) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Audio input set to ${device['name']}'), + duration: const Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error switching audio device: $e'), + duration: const Duration(seconds: 3), + backgroundColor: Colors.red, + ), + ); + } + } +} + +Widget _buildFloatingAudioDeviceDropdown() { + return Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + color: const Color(0xFF2C2C2E), + child: Container( + constraints: const BoxConstraints(maxWidth: 280, minWidth: 220), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withOpacity(0.1)), ), - const SizedBox(width: 24), - // System Audio - Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.volume_up_rounded, size: 16, color: ResponsiveHelper.textSecondary), - const SizedBox(width: 8), - const Text( - 'System', - style: TextStyle(fontSize: 13, color: ResponsiveHelper.textSecondary), + const Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Text('Select Audio Input', + style: TextStyle( + fontSize: 13, + color: Colors.white70, + fontWeight: FontWeight.w600)), ), - const SizedBox(width: 8), - _buildAudioLevelBar(systemLevel, Colors.orange.shade600), + Divider(height: 1, color: Colors.white.withOpacity(0.1)), + const SizedBox(height: 4), + if (_availableAudioDevices.isNotEmpty) + ..._availableAudioDevices.map(_buildFloatingAudioDeviceItem) + else + const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: Text('Loading devices...', + style: TextStyle( + fontSize: 12, + color: ResponsiveHelper.textSecondary)))), + const SizedBox(height: 4), ], ), - ], + ), + ); +} + +Widget _buildFloatingAudioDeviceItem(Map device) { + final isSelected = device['id'] == _selectedDeviceId; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _selectAudioDevice(device), + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: isSelected ? ResponsiveHelper.purplePrimary : Colors.grey[500]!, + width: 2, + ), + color: Colors.transparent, + ), + child: isSelected + ? Center( + child: Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: ResponsiveHelper.purplePrimary, + ), + ), + ) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + device['name'] ?? 'Unknown Device', + style: TextStyle( + fontSize: 14, + color: isSelected ? Colors.white : Colors.white70, + fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), ); } + String? _validateSelectedDevice(List> devices, String? currentId) { + if (currentId == null || currentId.isEmpty) { + return _findCurrentDeviceId(devices); + } + + final exists = devices.any((d) => d['id'] == currentId); + return exists ? currentId : _findCurrentDeviceId(devices); + } + + Future _loadAvailableAudioDevices() async { + final result = await const MethodChannel('screenCapturePlatform') + .invokeMethod('getAvailableAudioDevices'); + + if (result is List && mounted) { + final devices = result.map((device) => { + 'id': device['id']?.toString() ?? '', + 'name': device['name']?.toString() ?? 'Unknown Device', + }).toList(); + + setState(() { + _availableAudioDevices = devices; + _selectedDeviceId = _validateSelectedDevice(devices, _selectedDeviceId); + }); + } + } + + String? _findCurrentDeviceId(List> devices) { + if (devices.isEmpty) return null; + + final currentMicName = context.read().microphoneName; + if (currentMicName != null) { + final matchingDevice = devices.firstWhere( + (device) => device['name'] == currentMicName, + orElse: () => devices.first, + ); + return matchingDevice['id']; + } + return devices.first['id']; + } + Widget _buildCompactRecordingView( bool isInitializing, RecordingState recordingState, CaptureProvider captureProvider) { return Container( diff --git a/app/macos/Runner/AudioManager.swift b/app/macos/Runner/AudioManager.swift index 05d8e507131..2492667b0ee 100644 --- a/app/macos/Runner/AudioManager.swift +++ b/app/macos/Runner/AudioManager.swift @@ -139,7 +139,7 @@ class AudioManager: NSObject, SCStreamDelegate, SCStreamOutput { if let engine = audioEngine, engine.isRunning { engine.stop() - micNode?.removeTap(onBus: 0) + removeMicTap() } // Clean up resources. @@ -248,23 +248,54 @@ class AudioManager: NSObject, SCStreamDelegate, SCStreamOutput { } self.micNode = audioEngine?.inputNode - self.micNodeFormat = self.micNode!.outputFormat(forBus: 0) - self.micAudioConverter = AVAudioConverter(from: self.micNodeFormat!, to: outputFormat) + // Get the hardware input format + guard let micNode = self.micNode else { + throw AudioManagerError.audioFormatError("Could not get audio engine input node") + } + + let hardwareInputFormat = micNode.inputFormat(forBus: 0) + + // Validate the hardware input format + guard hardwareInputFormat.sampleRate > 0 && hardwareInputFormat.channelCount > 0 else { + throw AudioManagerError.audioFormatError("Invalid hardware input format: SR=\(hardwareInputFormat.sampleRate), CH=\(hardwareInputFormat.channelCount)") + } + + self.micAudioConverter = AVAudioConverter(from: hardwareInputFormat, to: outputFormat) guard self.micAudioConverter != nil else { - throw AudioManagerError.converterSetupError("Could not create main audio converter to Flutter format") + throw AudioManagerError.converterSetupError("Could not create audio converter from hardware input format to Flutter format") } self.micAudioConverter?.sampleRateConverterAlgorithm = AVSampleRateConverterAlgorithm_Mastering self.micAudioConverter?.sampleRateConverterQuality = .max self.micAudioConverter?.dither = true - print("DEBUG: Mic native format: \(self.micNodeFormat!))") - print("DEBUG: Flutter output format will be SR: \(Constants.flutterOutputSampleRate), CH: \(Constants.flutterOutputChannels)") + } + + private func removeMicTap() { + guard let micNode = micNode else { + return + } + + micNode.removeTap(onBus: 0) } private func installMicTap() { - micNode!.installTap(onBus: 0, bufferSize: Constants.micTapBufferSize, format: self.micNodeFormat!) { [weak self] (buffer, time) in + guard let micNode = micNode else { + print("ERROR: Cannot install mic tap - mic node is nil") + return + } + + removeMicTap() + + let hardwareInputFormat = micNode.inputFormat(forBus: 0) + + // Check if the format is valid for tap installation + guard hardwareInputFormat.sampleRate > 0 && hardwareInputFormat.channelCount > 0 else { + print("ERROR: Invalid hardware input format - cannot install tap") + return + } + micNode.installTap(onBus: 0, bufferSize: Constants.micTapBufferSize, format: hardwareInputFormat) { [weak self] (buffer, time) in guard let self = self, let finalConverter = self.micAudioConverter, let finalOutputFormat = self.outputAudioFormat else { return } @@ -890,6 +921,152 @@ class AudioManager: NSObject, SCStreamDelegate, SCStreamOutput { deviceListChangedListener = nil } + // MARK: - Audio Device Management + + func getAvailableAudioDevices() -> [[String: String]] { + var devices: [[String: String]] = [] + let deviceIDs = getAudioDeviceIDs() + + for deviceID in deviceIDs { + // Check if device has input streams + if hasInputStreams(deviceID: deviceID) { + if let deviceName = getDeviceName(for: deviceID) { + if !isAggregateOrVirtualDevice(deviceName: deviceName, deviceID: deviceID) { + let friendlyName = getFriendlyDeviceName(deviceName) + devices.append([ + "id": String(deviceID), + "name": friendlyName + ]) + } + } + } + } + + return devices + } + + private func isAggregateOrVirtualDevice(deviceName: String, deviceID: AudioDeviceID) -> Bool { + // Filter out aggregate devices + if deviceName.contains("CADefaultDeviceAggregate") || + deviceName.contains("Aggregate Device") || + deviceName.contains("Multi-Output Device") { + return true + } + + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyTransportType, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var transportType: UInt32 = 0 + var propertySize = UInt32(MemoryLayout.size) + + let status = AudioObjectGetPropertyData( + deviceID, + &address, + 0, + nil, + &propertySize, + &transportType + ) + + if status == noErr { + return transportType == kAudioDeviceTransportTypeVirtual || + transportType == kAudioDeviceTransportTypeAggregate + } + + return false + } + + private func getFriendlyDeviceName(_ deviceName: String) -> String { + if deviceName.contains("MacBook") && deviceName.contains("Microphone") { + return "MacBook Microphone" + } else if deviceName.contains("AirPods") { + return deviceName + } else if deviceName.contains("USB") { + return deviceName.replacingOccurrences(of: "USB ", with: "") + } else if deviceName.contains("Built-in") { + return "Built-in Microphone" + } + + return deviceName + } + + private func hasInputStreams(deviceID: AudioDeviceID) -> Bool { + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreams, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain + ) + + var propertySize: UInt32 = 0 + let status = AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &propertySize) + + return status == noErr && propertySize > 0 + } + + func selectAudioDevice(deviceID: String) -> Bool { + guard let deviceIDInt = AudioDeviceID(deviceID) else { + print("ERROR: Invalid device ID: \(deviceID)") + return false + } + + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var deviceIDToSet = deviceIDInt + let propertySize = UInt32(MemoryLayout.size) + + let status = AudioObjectSetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, + nil, + propertySize, + &deviceIDToSet + ) + + if status == noErr { + print("DEBUG: Successfully set default input device to ID: \(deviceID)") + + self.currentInputDeviceID = deviceIDInt + if let deviceName = getDeviceName(for: deviceIDInt) { + self.currentInputDeviceName = deviceName + print("DEBUG: New input device name: \(deviceName)") + } + + // If currently recording, restart the audio engine to use the new device + if _isRecording { + print("DEBUG: Recording is active, restarting audio engine with new device") + Task { + do { + audioEngine?.stop() + removeMicTap() + audioEngine?.reset() + try await Task.sleep(nanoseconds: 100_000_000) + try audioEngine?.prepare() + try configureAudioFormatsAndConverter() + installMicTap() + try audioEngine?.start() + if isFlutterEngineActive { + self.screenCaptureChannel?.invokeMethod("microphoneDeviceChanged", arguments: nil) + } + } catch { + print("ERROR: Failed to restart audio engine with new device: \(error)") + } + } + } + + return true + } else { + print("ERROR: Failed to set default input device: \(status)") + return false + } + } } // MARK: - Audio Manager Errors diff --git a/app/macos/Runner/MainFlutterWindow.swift b/app/macos/Runner/MainFlutterWindow.swift index ca7297af601..af3cc2cce8f 100644 --- a/app/macos/Runner/MainFlutterWindow.swift +++ b/app/macos/Runner/MainFlutterWindow.swift @@ -252,6 +252,19 @@ class MainFlutterWindow: NSWindow, NSWindowDelegate { } } + case "getAvailableAudioDevices": + let devices = self.audioManager.getAvailableAudioDevices() + result(devices) + + case "selectAudioDevice": + if let args = call.arguments as? [String: Any], + let deviceId = args["deviceId"] as? String { + let success = self.audioManager.selectAudioDevice(deviceID: deviceId) + result(success) + } else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Device ID is required", details: nil)) + } + default: result(FlutterMethodNotImplemented) }