diff --git a/desktop/backend/backend.py b/desktop/backend/backend.py index 66043e7..7cd075b 100644 --- a/desktop/backend/backend.py +++ b/desktop/backend/backend.py @@ -18,7 +18,12 @@ class BackendServer: def __init__(self): self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.server_socket.bind(('127.0.0.1', FLUTTER_PORT)) + try: + self.server_socket.bind(('127.0.0.1', FLUTTER_PORT)) + except OSError: + print(f"[!] Port {FLUTTER_PORT} is busy. Is the app already running?") + os._exit(1) + self.server_socket.listen(1) self.client_socket = None @@ -60,20 +65,49 @@ def start(self): self.cleanup() def _scan_devices(self): + """ + Scans output devices. + Improvement: Prioritizes Windows WASAPI (Low Latency) if available. + """ self.device_map = {} + info = self.p.get_host_api_info_by_index(0) + num_devices = info.get("deviceCount") + + # Helper to find WASAPI host API index on Windows + wasapi_index = -1 try: - info = self.p.get_host_api_info_by_index(0) - num_devices = info.get("deviceCount") - for i in range(num_devices): - device_info = self.p.get_device_info_by_host_api_device_index(0, i) - if device_info.get("maxOutputChannels") > 0: - name = device_info.get("name") - self.device_map[name] = i - except Exception: pass + for i in range(self.p.get_host_api_count()): + api = self.p.get_host_api_info_by_index(i) + if "WASAPI" in api.get("name", ""): + wasapi_index = i + break + except: pass + + for i in range(num_devices): + try: + dev = self.p.get_device_info_by_host_api_device_index(0, i) + if dev.get("maxOutputChannels") > 0: + name = dev.get("name") + + # If on Windows and we found a generic driver, try to find its WASAPI counterpart + target_index = i + if wasapi_index != -1: + # Scan for the same device name under WASAPI + for j in range(info.get("deviceCount") * 2): # Heuristic search + try: + w_dev = self.p.get_device_info_by_index(j) + if w_dev["hostApi"] == wasapi_index and w_dev["name"] in name: + target_index = j + break + except: continue + + self.device_map[name] = target_index + except: pass def send_to_flutter(self, data_dict): if self.client_socket: try: + # Append newline to ensure Flutter's LineSplitter catches it message = json.dumps(data_dict) + "\n" self.client_socket.sendall(message.encode('utf-8')) except: @@ -134,6 +168,10 @@ def process_command(self, cmd): def setup_adb(self, port): self.send_to_flutter({"type": "log", "message": "[*] Setting up ADB..."}) try: + # Remove old rules first to be clean + subprocess.run(["adb", "forward", "--remove", f"tcp:{port}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(["adb", "forward", f"tcp:{port}", f"tcp:{port}"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) return True @@ -142,7 +180,7 @@ def setup_adb(self, port): return False def _recv_exact(self, sock, n): - """Receive exactly n bytes from socket""" + """Receive exactly n bytes from socket with timeout handling""" data = b'' while len(data) < n: try: @@ -178,7 +216,7 @@ def audio_stream_logic(self, device_name, port): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - # --- ADD THIS: Reduce Receive Buffer to ~4 packets --- + # --- OPTIMIZATION 1: Small TCP Buffer to prevent lag --- sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4096) sock.settimeout(2) @@ -204,72 +242,61 @@ def audio_stream_logic(self, device_name, port): # ========== HANDSHAKE PROTOCOL ========== - # Step 1: Receive sample rate (4 bytes, big-endian signed int) + # Step 1: Receive sample rate header = self._recv_exact(sock, 4) - if not header or len(header) < 4: - raise Exception("Phone disconnected during handshake (sample rate).") - + if not header: raise Exception("Handshake failed (Sample Rate)") sample_rate = struct.unpack('>i', header)[0] if sample_rate <= 0 or sample_rate > 192000: - raise Exception(f"Invalid sample rate received: {sample_rate}") + raise Exception(f"Invalid sample rate: {sample_rate}") self.send_to_flutter({"type": "log", "message": f"[*] Sample Rate: {sample_rate} Hz"}) - # Step 2: Send acknowledgment (echo the sample rate back) + # Step 2: Send acknowledgment sock.sendall(struct.pack('>i', sample_rate)) - self.send_to_flutter({"type": "log", "message": "[*] Sent acknowledgment..."}) - # Step 3: Wait for READY signal (0x52454459 = "REDY") + # Step 3: Wait for READY signal ready_bytes = self._recv_exact(sock, 4) - if not ready_bytes or len(ready_bytes) < 4: - raise Exception("Phone disconnected during handshake (ready signal).") - + if not ready_bytes: raise Exception("Handshake failed (Ready Signal)") ready_signal = struct.unpack('>i', ready_bytes)[0] - if ready_signal != 0x52454459: + if ready_signal != 0x52454459: # "REDY" raise Exception(f"Invalid ready signal: {hex(ready_signal)}") self.send_to_flutter({"type": "log", "message": "[*] Handshake complete!"}) # ========== END HANDSHAKE ========== - # Remove timeout for streaming (or set a longer one) - sock.settimeout(10) - # --- AUDIO DEVICE SETUP --- device_index = self.device_map.get(device_name) if device_index is None: device_index = self.p.get_default_output_device_info()["index"] + # --- OPTIMIZATION 2: Match buffer size to Android (10ms = 480 frames at 48k) --- stream = self.p.open( format=pyaudio.paInt16, channels=1, rate=sample_rate, output=True, output_device_index=device_index, - frames_per_buffer=480 # <--- CHANGED FROM 2048 + frames_per_buffer=480 ) self.send_to_flutter({"type": "status", "payload": "running"}) - self.send_to_flutter({"type": "log", "message": "[*] Streaming audio..."}) - # --- ADD THIS: Flush the socket to remove startup latency --- + # --- OPTIMIZATION 3: Flush Startup Lag --- + # Drain any data that arrived while opening speakers to ensure we start "now" + sock.settimeout(0.0) # Non-blocking try: - sock.setblocking(0) # Non-blocking mode while True: - # Dump everything currently in the buffer - junk = sock.recv(4096) - if not junk: break - except BlockingIOError: - pass # Buffer is empty, we are now "live" - except Exception: - pass - finally: - sock.setblocking(1) # Back to blocking mode - # ----------------------------------------------------------- + if not sock.recv(4096): break + except BlockingIOError: pass + except Exception: pass + sock.settimeout(10.0) # Restore blocking - # --- STREAM LOOP (with length-prefixed packets) --- + self.send_to_flutter({"type": "log", "message": "[*] Streaming audio..."}) + + # --- STREAM LOOP --- consecutive_errors = 0 max_consecutive_errors = 5 @@ -283,54 +310,47 @@ def audio_stream_logic(self, device_name, port): length = struct.unpack('>i', length_bytes)[0] - # Validate length + # Validate length (Max 64KB safety check) if length <= 0 or length > 65536: consecutive_errors += 1 - self.send_to_flutter({"type": "log", "message": f"[!] Invalid packet length: {length}"}) - if consecutive_errors >= max_consecutive_errors: - raise Exception("Too many invalid packets") + if consecutive_errors >= max_consecutive_errors: break continue # Read audio data data = self._recv_exact(sock, length) - if not data: - self.send_to_flutter({"type": "log", "message": "[*] Connection closed during data read"}) - break + if not data: break - consecutive_errors = 0 # Reset on success + consecutive_errors = 0 - # --- RNNOISE INTEGRATION --- + # --- PROCESS --- final_data = data if self.use_rnnoise and self.rnnoise: try: + # RNNoise optimization handled inside denoiser.py processed = self.rnnoise.process(data) - if processed: - final_data = processed - else: - continue - except Exception as e: - print(f"RNNoise fail: {e}") - - # --- GAIN & PLAYBACK --- - audio_data = np.frombuffer(final_data, dtype=np.int16) + if processed: final_data = processed + else: continue + except Exception: pass + + # --- GAIN & VISUALIZER --- + audio_array = np.frombuffer(final_data, dtype=np.int16) if self.current_gain != 1.0: - audio_data = np.clip(audio_data * self.current_gain, -32768, 32767).astype(np.int16) - final_data = audio_data.tobytes() + audio_array = np.clip(audio_array * self.current_gain, -32768, 32767).astype(np.int16) + final_data = audio_array.tobytes() - # Send volume to Flutter - if len(audio_data) > 0: - rms = np.sqrt(np.mean(audio_data.astype(float)**2)) + # Send RMS to UI (Throttle to avoid flooding socket) + if len(audio_array) > 0 and int(time.time() * 10) % 2 == 0: + rms = np.sqrt(np.mean(audio_array.astype(float)**2)) self.send_to_flutter({"type": "volume", "value": min(rms / 2000, 1.0)}) stream.write(final_data) except socket.timeout: - self.send_to_flutter({"type": "log", "message": "[!] Read timeout, checking connection..."}) + self.send_to_flutter({"type": "log", "message": "[!] Read timeout..."}) consecutive_errors += 1 - if consecutive_errors >= max_consecutive_errors: - raise Exception("Connection timeout") + if consecutive_errors >= max_consecutive_errors: break continue except Exception as e: @@ -342,8 +362,7 @@ def audio_stream_logic(self, device_name, port): stream.close() except: pass if sock: - try: - sock.close() + try: sock.close() except: pass self.is_streaming = False @@ -358,4 +377,4 @@ def cleanup(self): except: pass if __name__ == "__main__": - BackendServer().start() \ No newline at end of file + BackendServer().start() diff --git a/desktop/frontend/lib/main.dart b/desktop/frontend/lib/main.dart index c031017..07a1ffc 100644 --- a/desktop/frontend/lib/main.dart +++ b/desktop/frontend/lib/main.dart @@ -13,47 +13,54 @@ void main() { ); } +// --- CONTROLLER --- class BackendController extends ChangeNotifier { Socket? _socket; Process? _pythonProcess; String status = "Initializing..."; - double currentVolume = 0.0; // Visualizer value (0.0 - 1.0) - double gainValue = 1.0; // Slider value (1.0 - 5.0) - bool isAiEnabled = false; + // Data State + double currentVolume = 0.0; + double gainValue = 1.0; + bool isAiEnabled = false; List logs = []; List devices = []; String? selectedDevice; + // TCP Buffer for fragmented packets + String _socketBuffer = ""; + BackendController() { _startEmbeddedBackend(); } void _startEmbeddedBackend() async { + // Locate the backend script relative to the executable String exePath = Platform.resolvedExecutable; String dir = File(exePath).parent.path; String scriptPath = p.join(dir, 'backend', 'backend.py'); - logs.add("Looking for script at: $scriptPath"); - notifyListeners(); + _log("Looking for script at: $scriptPath"); + // Check if script exists (Release mode vs Debug mode adjustments might be needed) if (await File(scriptPath).exists()) { try { _pythonProcess = await Process.start('python', [scriptPath]); - logs.add("Python backend started."); + _log("Python backend started."); + // Listen to Python's STDERR for debugging _pythonProcess!.stderr.transform(utf8.decoder).listen((data) { print("PY_ERR: $data"); - logs.add("PY_ERR: $data"); - notifyListeners(); + // Optional: Don't show every stderr in UI logs to keep it clean }); } catch (e) { - logs.add("Failed to launch python: $e"); + _log("Failed to launch python: $e"); } } else { - logs.add("backend.py not found (Dev mode?)"); + _log("backend.py not found. Assuming external/dev backend."); } + // Give it a moment to bind the port await Future.delayed(const Duration(seconds: 1)); connectToPython(); } @@ -70,31 +77,52 @@ class BackendController extends ChangeNotifier { _socket = await Socket.connect('127.0.0.1', 5000); status = "Connected to Engine"; notifyListeners(); + + // Request initial state sendCommand("get_devices"); _socket!.cast>().transform(utf8.decoder).listen( - (data) { - for (var line in data.split('\n')) { - if (line.trim().isNotEmpty) { - try { - _handleMessage(jsonDecode(line)); - } catch (e) { /* ignore partial packets */ } - } - } - }, + _onDataReceived, onDone: () { status = "Backend Disconnected"; + _socket = null; notifyListeners(); + _reconnect(); }, onError: (e) { status = "Connection Error"; + _socket = null; notifyListeners(); + _reconnect(); }, ); } catch (e) { status = "Waiting for Backend..."; - Future.delayed(const Duration(seconds: 2), connectToPython); notifyListeners(); + _reconnect(); + } + } + + void _reconnect() { + Future.delayed(const Duration(seconds: 2), connectToPython); + } + + // --- CRITICAL FIX: Handle Fragmented TCP Packets --- + void _onDataReceived(String data) { + _socketBuffer += data; + + while (_socketBuffer.contains('\n')) { + int index = _socketBuffer.indexOf('\n'); + String line = _socketBuffer.substring(0, index).trim(); + _socketBuffer = _socketBuffer.substring(index + 1); + + if (line.isNotEmpty) { + try { + _handleMessage(jsonDecode(line)); + } catch (e) { + print("JSON Error: $e | Line: $line"); + } + } } } @@ -104,17 +132,17 @@ class BackendController extends ChangeNotifier { status = msg['payload']; break; case 'volume': - // Visualizer update currentVolume = (msg['value'] as num).toDouble(); break; case 'log': - logs.add(msg['message']); + _log(msg['message']); break; case 'error': - logs.add("ERROR: ${msg['message']}"); + _log("ERROR: ${msg['message']}"); break; case 'devices': devices = List.from(msg['payload']); + // Auto-select first device if none selected if (devices.isNotEmpty && selectedDevice == null) { selectedDevice = devices.first; } @@ -123,6 +151,15 @@ class BackendController extends ChangeNotifier { notifyListeners(); } + void _log(String message) { + logs.add(message); + // Limit log size to prevent memory issues + if (logs.length > 200) { + logs.removeAt(0); + } + notifyListeners(); + } + void sendCommand(String cmd, [Map args = const {}]) { if (_socket != null) { Map commandData = {'command': cmd}..addAll(args); @@ -134,8 +171,7 @@ class BackendController extends ChangeNotifier { if (selectedDevice != null) { sendCommand('start', {'device_name': selectedDevice, 'port': 6000}); } else { - logs.add("ERROR: No output device selected!"); - notifyListeners(); + _log("ERROR: No output device selected!"); } } @@ -150,7 +186,6 @@ class BackendController extends ChangeNotifier { void setGain(double val) { gainValue = val; - // Send command to python to update multiplier sendCommand('set_gain', {'value': val}); notifyListeners(); } @@ -160,6 +195,10 @@ class BackendController extends ChangeNotifier { sendCommand("toggle_rnnoise", {"value": value}); notifyListeners(); } + + void refreshDevices() { + sendCommand("get_devices"); + } } // --- UI LAYOUT --- @@ -172,10 +211,11 @@ class MyApp extends StatelessWidget { title: 'MicRouter PC', debugShowCheckedModeBanner: false, theme: ThemeData.dark(useMaterial3: true).copyWith( + scaffoldBackgroundColor: Colors.grey[900], colorScheme: ColorScheme.dark( primary: Colors.cyanAccent, secondary: Colors.purpleAccent, - surface: Colors.grey[900]!, + surface: Colors.grey[850]!, ), ), home: const HomeScreen(), @@ -183,105 +223,326 @@ class MyApp extends StatelessWidget { } } -class HomeScreen extends StatelessWidget { +class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override - Widget build(BuildContext context) { - final controller = context.watch(); + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + int _selectedIndex = 0; + @override + Widget build(BuildContext context) { return Scaffold( body: Row( children: [ - // Sidebar + // --- NAVIGATION RAIL --- NavigationRail( - selectedIndex: 0, + selectedIndex: _selectedIndex, backgroundColor: Colors.black26, + labelType: NavigationRailLabelType.all, + onDestinationSelected: (int index) { + setState(() { + _selectedIndex = index; + }); + }, destinations: const [ - NavigationRailDestination(icon: Icon(Icons.mic), label: Text('Router')), - NavigationRailDestination(icon: Icon(Icons.settings), label: Text('Settings')), + NavigationRailDestination( + icon: Icon(Icons.mic_none_outlined), + selectedIcon: Icon(Icons.mic), + label: Text('Router'), + ), + NavigationRailDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: Text('Settings'), + ), ], - onDestinationSelected: (int index) {}, ), const VerticalDivider(thickness: 1, width: 1, color: Colors.white10), - // Main Content + // --- MAIN CONTENT AREA --- Expanded( + child: _selectedIndex == 0 + ? const RouterView() + : const SettingsView(), + ), + ], + ), + ); + } +} + +// --- VIEW 1: ROUTER DASHBOARD --- +class RouterView extends StatefulWidget { + const RouterView({super.key}); + + @override + State createState() => _RouterViewState(); +} + +class _RouterViewState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + + // Auto-scroll logs + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + }); + + return Padding( + padding: const EdgeInsets.all(40.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Dashboard", style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 40), + + // 1. Visualizer + Center( + child: Container( + height: 80, + width: double.infinity, + constraints: const BoxConstraints(maxWidth: 700), + decoration: BoxDecoration( + color: Colors.black45, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white12), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.3), blurRadius: 10, offset: const Offset(0, 4)) + ] + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: controller.currentVolume.clamp(0.0, 1.0), + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [Colors.cyanAccent, Colors.purpleAccent]), + ), + ), + ), + ), + ), + ), + + const SizedBox(height: 15), + + // Status Text + Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: controller.status.contains("running") ? Colors.green.withOpacity(0.2) : Colors.white10, + borderRadius: BorderRadius.circular(20) + ), + child: Text( + "STATUS: ${controller.status.toUpperCase()}", + style: TextStyle( + letterSpacing: 1.2, + fontSize: 12, + fontWeight: FontWeight.bold, + color: controller.status.contains("running") ? Colors.greenAccent : Colors.grey + ) + ), + ), + ), + + const SizedBox(height: 60), + + // 2. Start/Stop Action Buttons + Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton.icon( + onPressed: (controller.status == "running" || controller.selectedDevice == null) + ? null + : controller.startStreaming, + icon: const Icon(Icons.play_arrow), + label: const Text("START ROUTING"), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 22), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)) + ), + ), + const SizedBox(width: 20), + OutlinedButton.icon( + onPressed: controller.status == "running" ? controller.stopStreaming : null, + icon: const Icon(Icons.stop), + label: const Text("STOP"), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 22), + foregroundColor: Colors.redAccent, + side: const BorderSide(color: Colors.redAccent), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)) + ), + ), + ], + ), + ), + + const Spacer(), + const Divider(color: Colors.white10), + const SizedBox(height: 10), + + // 3. Logs Console + const Text("System Logs:", style: TextStyle(color: Colors.grey, fontSize: 12)), + Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.all(8), + height: 120, + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white10) + ), + child: ListView.builder( + controller: _scrollController, + itemCount: controller.logs.length, + itemBuilder: (ctx, i) { + return Text( + ">> ${controller.logs[i]}", + style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.white70), + ); + }, + ), + ) + ], + ), + ); + } +} + +// --- VIEW 2: SETTINGS PAGE --- +class SettingsView extends StatelessWidget { + const SettingsView({super.key}); + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + + return Padding( + padding: const EdgeInsets.all(40.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Audio Settings", style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 40), + + // 1. Output Device Selection + Card( + color: Colors.white.withOpacity(0.05), child: Padding( - padding: const EdgeInsets.all(40.0), + padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("Android Mic Router", style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold)), - const SizedBox(height: 40), - - // 1. Visualizer - Center( - child: Container( - height: 60, - width: double.infinity, - constraints: const BoxConstraints(maxWidth: 600), - decoration: BoxDecoration( - color: Colors.black45, - borderRadius: BorderRadius.circular(30), - border: Border.all(color: Colors.white12) - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(30), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: controller.currentVolume, - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient(colors: [Colors.cyan, Colors.purpleAccent]), - ), - ), - ), - ), - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Output Device", style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + IconButton( + icon: const Icon(Icons.refresh, size: 20), + onPressed: controller.refreshDevices, + tooltip: "Refresh Devices", + ) + ], ), const SizedBox(height: 10), - Center(child: Text(controller.status.toUpperCase(), style: const TextStyle(letterSpacing: 1.5, fontSize: 12, color: Colors.grey))), + DropdownButtonFormField( + value: controller.selectedDevice, + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + filled: true, + fillColor: Colors.black26 + ), + dropdownColor: Colors.grey[850], + isExpanded: true, + hint: const Text("Select a speaker..."), + items: controller.devices.map((String value) { + return DropdownMenuItem( + value: value, + child: Text(value, overflow: TextOverflow.ellipsis), + ); + }).toList(), + onChanged: controller.selectDevice, + ), + ], + ), + ), + ), - const SizedBox(height: 40), + const SizedBox(height: 20), - // 2. Controls Row + // 2. Gain & Effects + Card( + color: Colors.white.withOpacity(0.05), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // AI Toggle Row( children: [ - // Device Dropdown - Expanded( - flex: 2, - child: DropdownButtonFormField( - value: controller.selectedDevice, - decoration: const InputDecoration( - labelText: "Output Device", - border: OutlineInputBorder(), - filled: true, - ), - isExpanded: true, - items: controller.devices.map((String value) { - return DropdownMenuItem( - value: value, - child: Text(value, overflow: TextOverflow.ellipsis), - ); - }).toList(), - onChanged: controller.selectDevice, + const Icon(Icons.auto_awesome, color: Colors.amber), + const SizedBox(width: 15), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("AI Noise Cancellation", style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + Text("Reduces background noise using RNNoise", style: TextStyle(fontSize: 12, color: Colors.grey)), + ], ), ), - const SizedBox(width: 20), + Switch( + value: controller.isAiEnabled, + onChanged: (val) => controller.toggleAi(val), + activeColor: Colors.amber, + ), + ], + ), - // Gain Slider + const Divider(height: 30, color: Colors.white10), + + // Gain Slider + Row( + children: [ + const Icon(Icons.volume_up, color: Colors.cyanAccent), + const SizedBox(width: 15), Expanded( - flex: 2, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("Volume Boost: ${controller.gainValue.toStringAsFixed(1)}x"), + Text("Digital Gain (Boost): ${controller.gainValue.toStringAsFixed(1)}x", style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), Slider( value: controller.gainValue, min: 1.0, max: 5.0, divisions: 40, + activeColor: Colors.cyanAccent, label: "${controller.gainValue.toStringAsFixed(1)}x", onChanged: (val) => controller.setGain(val), ), @@ -290,84 +551,6 @@ class HomeScreen extends StatelessWidget { ), ], ), - - // ... Gain Slider ... - - const SizedBox(height: 20), - - // AI TOGGLE ROW - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white10), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.auto_awesome, color: Colors.amber), - const SizedBox(width: 10), - const Text("AI Noise Cancellation"), - const SizedBox(width: 10), - Switch( - value: controller.isAiEnabled, - onChanged: (val) => controller.toggleAi(val), - activeColor: Colors.amber, - ), - ], - ), - ), - - const SizedBox(height: 30), - // ... Start/Stop Buttons ... - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FilledButton.icon( - onPressed: controller.status.contains("running") ? null : controller.startStreaming, - icon: const Icon(Icons.play_arrow), - label: const Text("START RECEIVING"), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 22), - textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold) - ), - ), - const SizedBox(width: 20), - OutlinedButton.icon( - onPressed: controller.status.contains("running") ? controller.stopStreaming : null, - icon: const Icon(Icons.stop), - label: const Text("STOP"), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 22), - foregroundColor: Colors.redAccent, - side: const BorderSide(color: Colors.redAccent) - ), - ), - ], - ), - - const Spacer(), - const Divider(color: Colors.white10), - const SizedBox(height: 10), - - // 4. Logs - const Text("Logs:", style: TextStyle(color: Colors.grey, fontSize: 12)), - SizedBox( - height: 100, - child: ListView.builder( - reverse: true, // Auto-scroll to bottom - itemCount: controller.logs.length, - itemBuilder: (ctx, i) { - // Reverse index for list view - final logIndex = controller.logs.length - 1 - i; - return Text( - ">> ${controller.logs[logIndex]}", - style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.white70), - ); - }, - ), - ) ], ), ),