// ignore_for_file: deprecated_member_use, avoid_print // // xAI Grok Voice Agent — Realtime API integration. // // Flow: // 1) دریافت توکن کوتاه‌مدت از backend (POST /ai/voice/token) // 2) اتصال WebSocket به wss://api.x.ai/v1/realtime?model=grok-voice-latest // با subprotocol `xai-client-secret.` (API key اصلی هیچ‌گاه به // client فرستاده نمی‌شود). // 3) ارسال session.update با voice=leo + instructions فارسی + server VAD // 4) Mic capture با PCM16 mono 24kHz → base64 → input_audio_buffer.append // 5) Audio playback با flutter_sound feedFromStream در 24kHz PCM // 6) Server VAD: input_audio_buffer.speech_started → playback را قطع کن + // response.cancel بفرست // // Voice: 'leo' — صدای مردانه. می‌توان به Eve/Ara/Rex/Sal تغییر داد. import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui'; import 'package:audio_session/audio_session.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_sound/flutter_sound.dart' hide Codec; import 'package:flutter_sound_platform_interface/flutter_sound_platform_interface.dart' as fsp; import 'package:record/record.dart'; import 'package:web_socket_channel/io.dart'; import 'package:didvan/services/network/request.dart'; import 'package:didvan/services/network/request_helper.dart'; import 'package:didvan/views/widgets/didvan/text.dart'; class AiVoiceChatDialog extends StatefulWidget { const AiVoiceChatDialog({super.key}); @override State createState() => _AiVoiceChatDialogState(); } class _AiVoiceChatDialogState extends State with TickerProviderStateMixin { // ----- xAI realtime config ---------------------------------------- static const String _wsUrl = 'wss://api.x.ai/v1/realtime?model=grok-voice-latest'; static const String _voice = 'leo'; static const int _sampleRate = 24000; static const String _instructions = 'تو هوشان هستی، دستیار هوشمند دیدوان. همیشه فقط به زبان فارسی صحبت کن ' 'و هرگز از زبان دیگری استفاده نکن. مفید، دوستانه و حرفه‌ای باش. ' 'پاسخ‌های واضح و مختصر بده. در مورد فعالیت‌های غیرقانونی کمک نکن، ' 'مشاوره مضر نده، و اگر سؤالی شامل اطلاعات شخصی حساس یا موضوع پیچیده‌ای ' 'فراتر از توانایی تو بود، کاربر را به یک انسان ارجاع بده. درخواست‌ها ' 'را به صورت طبیعی تأیید کن و مکالمه‌گونه پاسخ بده.'; // ----- state ------------------------------------------------------ IOWebSocketChannel? _ws; StreamSubscription? _wsSub; bool _isConnected = false; bool _isSessionReady = false; String _statusText = 'در حال اتصال...'; bool _isRecording = false; bool _isAiSpeaking = false; bool _isUserSpeaking = false; // ----- audio ------------------------------------------------------ final AudioRecorder _audioRecorder = AudioRecorder(); final FlutterSoundPlayer _audioPlayer = FlutterSoundPlayer(); StreamSubscription? _audioStreamSubscription; bool _isPlayerInitialized = false; /// بافر صوت میکروفون قبل از آماده شدن session — تا session.updated /// نیامده، chunkها را داخل این لیست نگه می‌داریم و یک‌جا flush می‌کنیم. /// این یک نکته مهم UX است: بدون آن، اولین ۲۰۰-۷۰۰ms صحبت کاربر گم می‌شود. final List _micBufferBeforeReady = []; // ----- animation -------------------------------------------------- late AnimationController _orbController; late AnimationController _rippleController; late AnimationController _waveController; final List _audioWaveHeights = List.generate(40, (_) => 0.1); @override void initState() { super.initState(); _initAnimations(); _initAudio(); _connectXai(); } void _initAnimations() { _orbController = AnimationController( vsync: this, duration: const Duration(milliseconds: 2000), )..repeat(reverse: true); _rippleController = AnimationController( vsync: this, duration: const Duration(milliseconds: 2000), )..repeat(); _waveController = AnimationController( vsync: this, duration: const Duration(milliseconds: 50), )..addListener(() { if (_isRecording || _isAiSpeaking) { if (mounted) { setState(() { final random = math.Random(); for (int i = 0; i < _audioWaveHeights.length; i++) { double target = 0.1 + (random.nextDouble() * 0.4); if (_isAiSpeaking) target *= 1.5; if (_isUserSpeaking) target *= 2.0; _audioWaveHeights[i] = _audioWaveHeights[i] + (target - _audioWaveHeights[i]) * 0.2; } }); } } else { for (int i = 0; i < _audioWaveHeights.length; i++) { _audioWaveHeights[i] = _audioWaveHeights[i] * 0.9; } } }) ..repeat(); } Future _initAudio() async { try { // ⚠️ سشن صوتی روی speaker (مدیا) نه earpiece. usage=media برای // اطمینان از این که صدای پاسخ از بلندگوی اصلی پخش می‌شود. final session = await AudioSession.instance; await session.configure(AudioSessionConfiguration( avAudioSessionCategory: AVAudioSessionCategory.playAndRecord, avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.allowBluetooth | AVAudioSessionCategoryOptions.defaultToSpeaker, avAudioSessionMode: AVAudioSessionMode.voiceChat, androidAudioAttributes: const AndroidAudioAttributes( contentType: AndroidAudioContentType.speech, flags: AndroidAudioFlags.none, usage: AndroidAudioUsage.media, ), androidAudioFocusGainType: AndroidAudioFocusGainType.gain, )); await _audioPlayer.openPlayer(); // raw PCM 16-bit signed LE @ 24kHz mono — همان فرمتی که xAI می‌فرستد await _audioPlayer.startPlayerFromStream( codec: fsp.Codec.pcm16, numChannels: 1, sampleRate: _sampleRate, interleaved: true, bufferSize: 4096, ); _isPlayerInitialized = true; debugPrint('✅ Audio player initialized (PCM 16 @ ${_sampleRate}Hz)'); } catch (e) { debugPrint('❌ Error initializing audio: $e'); } } /// توکن کوتاه‌مدت xAI از backend می‌گیریم — API key اصلی روی سرور باقی /// می‌ماند. بدون این، API key xAI به همراه اپ deploy می‌شد که ناامن است. Future _fetchEphemeralToken() async { try { final service = RequestService( '${RequestHelper.newApiBaseUrl}/ai/voice/token', ); await service.post(); if (!service.isSuccess) { debugPrint('❌ voice token fetch failed: ${service.errorMessage}'); return null; } final token = service.result['token']?.toString(); if (token == null || token.isEmpty) { debugPrint( '❌ voice token endpoint returned empty: ${service.result}'); return null; } return token; } catch (e) { debugPrint('❌ exception fetching voice token: $e'); return null; } } Future _connectXai() async { final token = await _fetchEphemeralToken(); if (token == null) { if (mounted) { setState(() => _statusText = 'اتصال ناموفق — توکن دریافت نشد'); } return; } try { // ⚠️ web_socket_channel در dart:io از parameter `protocols` برای // subprotocol استفاده می‌کند. xAI ephemeral token را از همین مسیر // می‌گیرد. final webSocket = await WebSocket.connect( _wsUrl, protocols: ['xai-client-secret.$token'], ); _ws = IOWebSocketChannel(webSocket); _wsSub = _ws!.stream.listen( _handleServerMessage, onError: (e) { debugPrint('❌ WS error: $e'); if (mounted) { setState(() { _isConnected = false; _statusText = 'خطای اتصال'; }); } }, onDone: () { debugPrint('🔌 WS closed'); if (mounted) { setState(() { _isConnected = false; _isSessionReady = false; _statusText = 'اتصال قطع شد'; }); } }, ); if (mounted) { setState(() { _isConnected = true; _statusText = 'پیکربندی نشست...'; }); } // session.update — اولین پیام بعد از open _send({ 'type': 'session.update', 'session': { 'voice': _voice, 'instructions': _instructions, 'turn_detection': {'type': 'server_vad'}, 'tools': [ {'type': 'web_search'}, {'type': 'x_search'}, ], 'input_audio_transcription': {'model': 'grok-2-audio'}, 'audio': { 'input': { 'format': {'type': 'audio/pcm', 'rate': _sampleRate}, }, 'output': { 'format': {'type': 'audio/pcm', 'rate': _sampleRate}, }, }, }, }); // mic capture را موازی شروع می‌کنیم — قبل از session.updated بافر // می‌شود تا اولین کلمات کاربر گم نشود. await _startMicCapture(); } catch (e) { debugPrint('❌ Failed to connect xAI: $e'); if (mounted) { setState(() => _statusText = 'اتصال ناموفق'); } } } void _send(Map message) { try { _ws?.sink.add(jsonEncode(message)); } catch (e) { debugPrint('❌ WS send failed: $e'); } } /// رویدادهای ورودی از xAI realtime. void _handleServerMessage(dynamic raw) { try { final event = jsonDecode(raw is String ? raw : utf8.decode(raw)) as Map; final type = event['type']?.toString() ?? ''; switch (type) { case 'session.created': debugPrint('🟢 session.created'); break; case 'session.updated': debugPrint('🟢 session.updated — flushing mic buffer'); _isSessionReady = true; // flush buffered audio for (final chunk in _micBufferBeforeReady) { _send({ 'type': 'input_audio_buffer.append', 'audio': base64Encode(chunk), }); } _micBufferBeforeReady.clear(); if (mounted) { setState(() => _statusText = 'گوش می‌دهم...'); } break; case 'input_audio_buffer.speech_started': // کاربر شروع به صحبت کرد — پخش AI را قطع کن و response را cancel کن debugPrint('🎤 speech_started — interrupt AI'); _interruptAi(); if (mounted) setState(() => _isUserSpeaking = true); break; case 'input_audio_buffer.speech_stopped': if (mounted) setState(() => _isUserSpeaking = false); break; case 'response.created': if (mounted) { setState(() { _isAiSpeaking = true; _statusText = 'در حال پاسخ...'; }); } break; case 'response.output_audio.delta': final delta = event['delta']?.toString(); if (delta != null && delta.isNotEmpty) { _playPcmChunk(delta); } break; case 'response.output_audio_transcript.delta': // transcript live — می‌توان در UI نشان داد. فعلاً فقط لاگ final delta = event['delta']?.toString() ?? ''; if (delta.isNotEmpty) debugPrint('💬 AI: $delta'); break; case 'response.done': debugPrint('✅ response.done'); if (mounted) { setState(() { _isAiSpeaking = false; _statusText = 'گوش می‌دهم...'; }); } break; case 'error': final msg = event['message']?.toString() ?? 'unknown'; debugPrint('⚠️ xAI error: $msg'); if (mounted) { setState(() => _statusText = 'خطا: $msg'); } break; default: // many other events — only verbose log if (kDebugMode) debugPrint('📨 $type'); } } catch (e) { debugPrint('❌ parse message error: $e'); } } /// PCM 16-bit signed LE chunk از base64 → دیکد و به player stream feed Future _playPcmChunk(String base64Chunk) async { if (!_isPlayerInitialized) return; try { final bytes = base64Decode(base64Chunk); await _audioPlayer.feedUint8FromStream(bytes); } catch (e) { debugPrint('❌ playPcmChunk error: $e'); } } Future _interruptAi() async { // 1) playback را قطع کن try { if (_isPlayerInitialized) { // فقط stream را خالی نمی‌توان کرد — راحت‌ترین راه: stop + restart await _audioPlayer.stopPlayer(); await _audioPlayer.startPlayerFromStream( codec: fsp.Codec.pcm16, numChannels: 1, sampleRate: _sampleRate, interleaved: true, bufferSize: 4096, ); } } catch (e) { debugPrint('❌ stop player on interrupt: $e'); } // 2) به xAI بگو response را cancel کن _send({'type': 'response.cancel'}); if (mounted) { setState(() { _isAiSpeaking = false; }); } } Future _startMicCapture() async { try { if (!await _audioRecorder.hasPermission()) { debugPrint('❌ mic permission denied'); if (mounted) setState(() => _statusText = 'دسترسی به میکروفون رد شد'); return; } final stream = await _audioRecorder.startStream(const RecordConfig( encoder: AudioEncoder.pcm16bits, sampleRate: _sampleRate, numChannels: 1, echoCancel: true, noiseSuppress: true, autoGain: true, )); _isRecording = true; _audioStreamSubscription = stream.listen((chunk) { // chunk شامل PCM 16-bit signed LE با sample rate خواسته‌شده هست. if (_isSessionReady) { _send({ 'type': 'input_audio_buffer.append', 'audio': base64Encode(chunk), }); } else { // قبل از session.updated بافر کن (با safety cap ~10s) _micBufferBeforeReady.add(chunk); // ~24kHz * 2 bytes * 10s = 480000 bytes var total = 0; for (final c in _micBufferBeforeReady) { total += c.length; } if (total > 480000) { _micBufferBeforeReady.removeAt(0); } } }); debugPrint('🎙️ mic capture started @ ${_sampleRate}Hz PCM16'); } catch (e) { debugPrint('❌ mic start error: $e'); if (mounted) setState(() => _statusText = 'خطا در میکروفون'); } } Future _stopAll() async { try { await _audioStreamSubscription?.cancel(); _audioStreamSubscription = null; } catch (_) {} try { if (await _audioRecorder.isRecording()) { await _audioRecorder.stop(); } } catch (_) {} try { if (_isPlayerInitialized) { await _audioPlayer.stopPlayer(); await _audioPlayer.closePlayer(); } } catch (_) {} try { await _wsSub?.cancel(); } catch (_) {} try { await _ws?.sink.close(); } catch (_) {} _ws = null; _isRecording = false; _isConnected = false; _isSessionReady = false; } @override void dispose() { _orbController.dispose(); _rippleController.dispose(); _waveController.dispose(); _stopAll(); super.dispose(); } // ----- UI --------------------------------------------------------- @override Widget build(BuildContext context) { final theme = Theme.of(context); return BackdropFilter( filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), child: Dialog( backgroundColor: theme.colorScheme.surface.withOpacity(0.95), insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), child: SizedBox( width: double.infinity, height: 480, child: Padding( padding: const EdgeInsets.all(20), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ DidvanText( 'گفتگوی صوتی با هوشان', fontSize: 16, fontWeight: FontWeight.bold, color: theme.colorScheme.primary, ), IconButton( icon: const Icon(Icons.close), onPressed: () async { await _stopAll(); if (mounted) Navigator.of(context).pop(); }, ), ], ), const SizedBox(height: 16), Expanded(child: _buildOrb()), const SizedBox(height: 12), _buildWave(), const SizedBox(height: 12), DidvanText( _statusText, fontSize: 13, color: theme.colorScheme.onSurface.withOpacity(0.7), ), const SizedBox(height: 12), ], ), ), ), ), ); } Widget _buildOrb() { return AnimatedBuilder( animation: Listenable.merge([_orbController, _rippleController]), builder: (context, _) { final orbScale = 1 + (_isAiSpeaking ? 0.15 : 0.05) * _orbController.value; final rippleRadius = 80 + 60 * _rippleController.value; final rippleOpacity = (1 - _rippleController.value) * (_isAiSpeaking || _isUserSpeaking ? 0.7 : 0.3); final color = _isUserSpeaking ? Colors.greenAccent : (_isAiSpeaking ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.secondary); return SizedBox( width: 220, height: 220, child: Stack( alignment: Alignment.center, children: [ Container( width: rippleRadius * 2, height: rippleRadius * 2, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: color.withOpacity(rippleOpacity), width: 2), ), ), Transform.scale( scale: orbScale, child: Container( width: 120, height: 120, decoration: BoxDecoration( shape: BoxShape.circle, gradient: RadialGradient( colors: [ color.withOpacity(0.9), color.withOpacity(0.5), color.withOpacity(0.2), ], ), ), child: Icon( _isUserSpeaking ? Icons.mic : (_isAiSpeaking ? Icons.graphic_eq : Icons.smart_toy), size: 48, color: Colors.white, ), ), ), ], ), ); }, ); } Widget _buildWave() { return SizedBox( height: 40, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: List.generate(_audioWaveHeights.length, (i) { final h = (_audioWaveHeights[i] * 40).clamp(2.0, 40.0); return AnimatedContainer( duration: const Duration(milliseconds: 80), width: 3, height: h, decoration: BoxDecoration( color: _isUserSpeaking ? Colors.greenAccent : Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.circular(2), ), ); }), ), ); } }