// 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 وقتی بلندگو فعال نیست → // اینتراپت؛ وقتی بلندگو فعال است (اکو) → فقط input_audio_buffer.clear // // Voice: 'leo' — صدای مردانه. می‌توان به Eve/Ara/Rex/Sal تغییر داد. import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'dart:ui' as ui; import 'package:audio_session/audio_session.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:mp_audio_stream/mp_audio_stream.dart'; 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'; class _QueuedPcmChunk { const _QueuedPcmChunk(this.samples, this.generation); final Float32List samples; final int generation; } class _PlaybackReferenceChunk { const _PlaybackReferenceChunk({ required this.samples, required this.startsAt, required this.endsAt, }); final Float32List samples; final DateTime startsAt; final DateTime endsAt; } class _VoiceMessage { const _VoiceMessage(this.text); final String text; } enum _VoiceVisualState { connecting, listening, hearing, searching, thinking, speaking, muted, error, } class AiVoiceChatDialog extends StatefulWidget { const AiVoiceChatDialog({super.key}); @override State createState() => _AiVoiceChatDialogState(); } class _AiVoiceChatDialogState extends State with TickerProviderStateMixin { static const MethodChannel _audioEventsChannel = MethodChannel('com.didvan.didvanapp/audio_events'); // ----- xAI realtime config ---------------------------------------- static const String _wsUrl = 'wss://api.x.ai/v1/realtime?model=grok-voice-latest'; static const String _voice = 'leo'; // ⚠️ 24 kHz — must match what xAI's realtime API accepts. A 16 kHz // experiment broke session.update entirely (session.updated never came // back → mic audio was buffered forever → AI silent). Don't change // without verifying the session handshake still completes. static const int _sampleRate = 24000; static const int _playerBufferMillis = 30000; static const int _initialJitterBufferMilliseconds = 260; static const int _nativeRecoveryBufferMilliseconds = 10; static const int _playbackDrainPaddingMilliseconds = 20; static const double _playbackGain = 1.35; // Barge-in tuning. Loose enough that a normally-spoken word or two // interrupts, strict enough that AI's own speaker echo doesn't. The // echo-cooldown safety net covers the leftover cases where echo does // sneak through — even a false barge-in gets scrubbed by the // input_audio_buffer.clear inside _interruptAi. // Android AEC attenuates the user's own voice hard while the speaker is // active — measured real speech lands around 0.02–0.04 RMS. The old floor // (0.055) was physically unreachable for most users, so voice barge-in // never fired. Echo rejection is the correlation gate's responsibility, // not a high RMS floor. // Measured on the user's device: speaker echo during playback lands at // 0.006–0.011 RMS, open-mic speech at ~0.066. AEC ducks the user's voice // during playback to roughly 0.015–0.03, so the floor sits between the // echo band and the ducked-voice band. Correlated echo is filtered // separately by _playbackEchoCorrelation. // ⚠️ Measured reality on this hardware at raised volume: speaker echo // reaches rms 0.03–0.094 and speaker distortion destroys waveform // correlation (0.10–0.51 on pure echo), so energy alone can never separate // a quiet user from loud echo. The floor is therefore deliberately LOW: // candidates are cheap, and the final decision belongs to the // output-ducking probe — echo dies when the speaker is muted, a real // user (even a quiet one) keeps talking. // This is a probe trigger, not a final voice decision. Speaker echo and an // AEC-ducked nearby user overlap on this device, so energy alone must never // execute an interrupt. static const double _bargeInRmsThreshold = 0.012; static const int _bargeInMinLoudChunks = 5; static const int _bargeInMinLoudMilliseconds = 350; static const int _bargeInProbeTriggerChunks = 3; static const int _bargeInProbeTriggerMilliseconds = 160; static const int _bargeInProbeEchoTailMilliseconds = 180; static const int _bargeInProbeDurationMilliseconds = 460; static const int _bargeInProbeRequiredVoiceChunks = 2; static const double _bargeInProbeVoiceFloor = 0.009; // Wait this long after AI starts speaking before ARMING barge-in so // echo baseline stabilizes on the AI's own voice first. static const int _bargeInArmDelayMilliseconds = 400; static const int _volumeGainSettlingMilliseconds = 1400; static const int _naturalPlaybackEchoTailMilliseconds = 500; static const int _postInterruptEchoTailMilliseconds = 120; static const double _strongEchoCorrelation = 0.72; static const double _possibleEchoCorrelation = 0.50; String get _instructions { final now = DateTime.now(); final today = '${now.year.toString().padLeft(4, '0')}-' '${now.month.toString().padLeft(2, '0')}-' '${now.day.toString().padLeft(2, '0')}'; return 'تو هوشان هستی، دستیار هوشمند دیدوان. همیشه فقط به زبان فارسی صحبت کن ' 'و هرگز از زبان دیگری استفاده نکن. مفید، دوستانه و حرفه‌ای باش. ' 'پاسخ‌های واضح و مختصر بده. تاریخ امروز $today و منطقه زمانی کاربر Asia/Tehran است. ' 'برای هر پرسش اطلاعاتی یا واقعیت‌محور، مخصوصاً خبر، قیمت، آب‌وهوا، افراد، ' 'شرکت‌ها، سیاست، ورزش، آمار، زمان‌بندی و هر موضوعی که ممکن است تغییر کرده باشد، ' 'قبل از پاسخ حتماً web_search را اجرا کن و فقط بر اساس نتیجه جست‌وجو جواب بده. ' 'برای اطلاعات شبکه X در صورت نیاز x_search را هم اجرا کن. هرگز درباره اطلاعات ' 'به‌روز از حافظه حدس نزن و هرگز ادعا نکن که به وب دسترسی نداری. ' 'اگر متن تشخیص‌داده‌شده از صدای کاربر مبهم، ناقص یا بی‌معنی است، حدس نزن؛ ' 'کوتاه از کاربر بخواه جمله را دوباره و واضح‌تر تکرار کند. ' 'در مورد فعالیت‌های غیرقانونی کمک نکن، مشاوره مضر نده، و اگر سؤالی شامل ' 'اطلاعات شخصی حساس یا موضوع پیچیده‌ای فراتر از توانایی تو بود، کاربر را ' 'به یک انسان ارجاع بده.'; } // ----- state ------------------------------------------------------ IOWebSocketChannel? _ws; StreamSubscription? _wsSub; Timer? _reconnectTimer; int _reconnectAttempt = 0; int _connectionGeneration = 0; int _lastDisconnectedGeneration = 0; bool _isConnecting = false; bool _isStopping = false; String? _conversationId; bool _isSessionReady = false; String _statusText = 'در حال اتصال...'; bool _isRecording = false; bool _isStartingRecorder = false; bool _isMicMuted = false; bool _isAiSpeaking = false; bool _isUserSpeaking = false; bool _isUserAudioLocked = false; Timer? _unlockUserMicTimer; // Absolute wall-clock cutoff: mic bytes MUST NOT reach the server before // this moment. Set whenever AI audio is being played (extended on each // audio chunk). Purpose: guarantee the speaker's echo tail can never // reach xAI's server VAD and trigger a self-answering loop, even if // _isAiSpeaking / _isUserAudioLocked state gets confused. DateTime? _echoCooldownUntil; static const int _echoCooldownAfterChunkMs = 500; // ----- audio ------------------------------------------------------ final AudioRecorder _audioRecorder = AudioRecorder(); // mp_audio_stream replaces flutter_sound for playback — flutter_sound's // FeedThread had a native race (AudioTrack::releaseBuffer SIGSEGV) that // couldn't be closed from Dart. mp_audio_stream owns its own ring buffer // and never releases the AudioTrack while feeds are pending. final AudioStream _audioPlayer = getAudioStream(); StreamSubscription? _audioStreamSubscription; Timer? _micHealthTimer; DateTime? _lastMicChunkAt; DateTime? _lastMicChunkSentAt; int _micChunksReceived = 0; int _micChunksSent = 0; bool _isRestartingMic = false; bool _isPlayerInitialized = false; final ListQueue<_QueuedPcmChunk> _pendingPlaybackChunks = ListQueue(); final ListQueue<_PlaybackReferenceChunk> _playbackReferences = ListQueue(); Timer? _playbackPumpTimer; Timer? _playbackCompletionTimer; DateTime? _estimatedPlaybackEnd; bool _serverAudioDone = false; bool _playbackSegmentSealed = false; bool _playbackSegmentStarted = false; int _pendingPlaybackSampleCount = 0; // output_audio.done can mark only one spoken segment around a built-in tool // call. Keep it separate from response.done, otherwise a web-search pause // clears the active response id and the follow-up audio deltas get rejected. bool _responseLifecycleDone = false; int _audioDeltasReceived = 0; int _audioSamplesQueued = 0; int _bargeInLoudChunks = 0; int _bargeInLoudSamples = 0; bool _bargeInProbeActive = false; DateTime? _bargeInProbeEvaluateAfter; int _bargeInProbeVoiceChunks = 0; double _bargeInProbePeakRms = 0; Timer? _bargeInProbeTimer; // Feedback loop against echo-triggered false probes: remember what energy // triggered the last probe and how many consecutive probes were rejected, // so rejections can lift the echo baseline and back off exponentially. double _lastBargeInTriggerRms = 0; int _bargeInProbeRejections = 0; double _echoRmsBaseline = 0; int _rmsLogSkip = 0; int _echoLogSkip = 0; int _tailBargeInLogSkip = 0; DateTime? _allowBargeInAfter; // On an actual user interrupt, bump generation and drain all pending audio. int _playerGeneration = 0; /// بافر صوت میکروفون قبل از آماده شدن session — تا session.updated /// نیامده، chunkها را داخل این لیست نگه می‌داریم و یک‌جا flush می‌کنیم. /// این یک نکته مهم UX است: بدون آن، اولین ۲۰۰-۷۰۰ms صحبت کاربر گم می‌شود. final List _micBufferBeforeReady = []; int _micBufferedBytes = 0; static const int _maxPreSessionAudioBytes = _sampleRate * 2 * 10; // ----- animation -------------------------------------------------- late AnimationController _orbController; late AnimationController _rippleController; late AnimationController _waveController; final List _audioWaveHeights = List.generate(40, (_) => 0.1); final ScrollController _transcriptController = ScrollController(); final List<_VoiceMessage> _messages = []; String _liveAiTranscript = ''; double _aiAudioLevel = 0; double _aiVoiceTexture = 0; double _userAudioLevel = 0; double _userVoiceTexture = 0; double _orbEnvelope = 0; double _orbVelocity = 0; double _orbTransient = 0; double _orbTexture = 0; double _orbMotionPhase = 0; double _previousOrbDrive = 0; ui.FragmentShader? _orbShader; @override void initState() { super.initState(); _audioEventsChannel.setMethodCallHandler(_handlePlatformAudioEvent); _initAnimations(); _loadOrbShader(); _initializeVoiceChat(); // Self-healing watchdog: the mic gating stack (_assistantOwnsAudioTurn // + echo cooldown) has several state producers; a single missed // lifecycle event (dropped response.done, cancelled tool call, stray // response id) leaves the user permanently muted. Every 2s, if we // claim the assistant owns the turn but no assistant audio has arrived // or played for 10s, force-release the turn. _turnWatchdog = Timer.periodic( const Duration(seconds: 2), (_) => _turnWatchdogTick(), ); } Timer? _turnWatchdog; DateTime? _lastAssistantAudioAt; void _turnWatchdogTick() { if (!mounted || _isStopping) return; if (!_assistantOwnsAudioTurn) return; // ⚠️ Do NOT call _audioPlayer.resume() here "just in case" — on this // device/package combination repeated resume() killed playback // entirely (no audio at all, transcript only). If mid-sentence audio // pauses come back, they need a targeted fix with stat() diagnostics, // not a blind periodic resume. if (_isPlayerInitialized && _playbackSegmentStarted) { try { final stat = _audioPlayer.stat(); debugPrint( '📈 player stat: full=${stat.full} exhaust=${stat.exhaust} ' 'queued=${_pendingPlaybackChunks.length} ' 'endsIn=${_estimatedPlaybackEnd?.difference(DateTime.now()).inMilliseconds}ms', ); } catch (_) {} } var last = _lastAssistantAudioAt; if (last == null) { // No audio yet in this turn — START the idle clock now instead of // treating "null" as infinitely idle. The old sentinel (1<<30 ms) // made the watchdog fire in the gap between response.created and the // first audio delta, nulling _currentResponseId — after which every // arriving audio delta was rejected and the user got text-only // responses with no sound. last = DateTime.now(); _lastAssistantAudioAt = last; return; } final idleMs = DateTime.now().difference(last).inMilliseconds; final queueEmpty = _pendingPlaybackChunks.isEmpty; final playbackOver = _estimatedPlaybackEnd == null || _estimatedPlaybackEnd!.isBefore(DateTime.now()); if (idleMs > 10000 && queueEmpty && playbackOver) { debugPrint( '🛟 turn watchdog: releasing stale assistant turn (idle=${idleMs}ms ' 'responseId=$_currentResponseId)'); _responseLifecycleDone = true; _finishPlaybackTurn(_playerGeneration); _currentResponseId = null; _echoCooldownUntil = null; } } Future _handlePlatformAudioEvent(MethodCall call) async { if (call.method != 'volumeChanged' || !_isAiSpeaking) return; // Raising/lowering the hardware volume changes speaker-to-mic gain // instantly, while Android's AEC needs a short window to converge again. // During that window learn the new echo floor instead of interpreting the // louder copy of Houshan's voice as a user interruption. final settlesAt = DateTime.now().add( const Duration(milliseconds: _volumeGainSettlingMilliseconds), ); final currentlyAllowedAfter = _allowBargeInAfter; if (currentlyAllowedAfter == null || settlesAt.isAfter(currentlyAllowedAfter)) { _allowBargeInAfter = settlesAt; } _bargeInLoudChunks = 0; _bargeInLoudSamples = 0; debugPrint('🔉 volume changed — re-learning speaker echo for ' '${_volumeGainSettlingMilliseconds}ms'); } Future _loadOrbShader() async { try { final program = await ui.FragmentProgram.fromAsset('lib/shaders/ai_orb_3d.frag'); if (!mounted) return; setState(() => _orbShader = program.fragmentShader()); } catch (error) { debugPrint('❌ AI orb shader load failed: $error'); } } Future _initializeVoiceChat() async { await _initAudio(); if (!mounted || _isStopping) return; // Capture immediately, before token fetch + WebSocket setup. Chunks are // retained in _micBufferBeforeReady and flushed only after session.updated, // so speech during the first connection seconds is no longer lost. await _startMicCapture(); if (!mounted || _isStopping) return; await _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(() { _aiAudioLevel *= 0.9; _aiVoiceTexture *= 0.91; _userAudioLevel *= 0.82; _userVoiceTexture *= 0.9; final level = _isAiSpeaking ? _aiAudioLevel : _userAudioLevel; final userDrive = (!_isMicMuted && !_assistantOwnsAudioTurn) ? ((_userAudioLevel - 0.13) / 0.67).clamp(0.0, 1.0).toDouble() : 0.0; final orbDrive = _isAiSpeaking ? _aiAudioLevel : userDrive; final textureDrive = _isAiSpeaking ? _aiVoiceTexture : _userVoiceTexture * userDrive; final envelopeDelta = orbDrive - _orbEnvelope; final envelopeResponse = envelopeDelta > 0 ? 0.38 : 0.1; final previousEnvelope = _orbEnvelope; _orbEnvelope = (_orbEnvelope + envelopeDelta * envelopeResponse) .clamp(0.0, 1.0) .toDouble(); final onset = math.max(0.0, orbDrive - _previousOrbDrive); _orbTransient = math .max(_orbTransient * 0.76, onset * 1.75) .clamp(0.0, 1.0) .toDouble(); _orbVelocity = (_orbVelocity * 0.72) + ((_orbEnvelope - previousEnvelope) * 0.7); _orbTexture += (textureDrive - _orbTexture) * (textureDrive > _orbTexture ? 0.28 : 0.12); _orbMotionPhase = (_orbMotionPhase + 0.018 + _orbEnvelope * 0.038 + _orbTexture * 0.012) % (math.pi * 2); _previousOrbDrive = orbDrive; for (int i = 0; i < _audioWaveHeights.length; i++) { final phase = i / _audioWaveHeights.length * math.pi * 5; final organic = 0.55 + 0.45 * math.sin(phase + _orbController.value * 8); final target = 0.06 + level * (0.34 + organic * 0.66); _audioWaveHeights[i] = _audioWaveHeights[i] + (target - _audioWaveHeights[i]) * 0.28; } }); } } else { for (int i = 0; i < _audioWaveHeights.length; i++) { _audioWaveHeights[i] = _audioWaveHeights[i] * 0.9; } } }) ..repeat(); } void _scrollTranscriptToEnd() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_transcriptController.hasClients) return; _transcriptController.animateTo( _transcriptController.position.maxScrollExtent, duration: const Duration(milliseconds: 280), curve: Curves.easeOutCubic, ); }); } void _appendAiTranscript(String delta) { if (!mounted || delta.isEmpty) return; setState(() => _liveAiTranscript += delta); _scrollTranscriptToEnd(); } void _commitAiTranscript([String? finalText]) { if (!mounted) return; final text = (finalText?.trim().isNotEmpty ?? false) ? finalText!.trim() : _liveAiTranscript.trim(); if (text.isEmpty) return; setState(() { _messages.add(_VoiceMessage(text)); _liveAiTranscript = ''; }); _scrollTranscriptToEnd(); } Future _initAudio() async { try { 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, // Live conversational assistants should declare USAGE_ASSISTANT. // This keeps semantic routing/focus distinct from generic media // without switching Android into the low-volume CALL stream. usage: AndroidAudioUsage.assistant, ), androidAudioFocusGainType: AndroidAudioFocusGainType.gain, )); // mp_audio_stream: init once with sample rate + channel count. // Player runs its own ring buffer; we feed Float32 chunks below. final initResult = _audioPlayer.init( channels: 1, sampleRate: _sampleRate, bufferMilliSec: _playerBufferMillis, waitingBufferMilliSec: _nativeRecoveryBufferMilliseconds, ); if (initResult != 0) { throw StateError('mp_audio_stream init failed: $initResult'); } _audioPlayer.resume(); _isPlayerInitialized = true; debugPrint( '✅ Audio player initialized (mp_audio_stream PCM16 @ ${_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 { if (_isStopping || !mounted || _isConnecting) return; _isConnecting = true; _reconnectTimer?.cancel(); _reconnectTimer = null; final generation = ++_connectionGeneration; if (mounted) { setState(() { _statusText = _reconnectAttempt == 0 ? 'در حال اتصال...' : 'در حال اتصال مجدد...'; }); } final token = await _fetchEphemeralToken(); if (_isStopping || !mounted || generation != _connectionGeneration) { _isConnecting = false; return; } if (token == null) { _isConnecting = false; if (mounted) { setState(() => _statusText = 'اتصال ناموفق — توکن دریافت نشد'); } _scheduleReconnect(); return; } WebSocket? webSocket; try { // ⚠️ web_socket_channel در dart:io از parameter `protocols` برای // subprotocol استفاده می‌کند. xAI ephemeral token را از همین مسیر // می‌گیرد. final resumeId = _conversationId; final url = resumeId == null ? _wsUrl : '$_wsUrl&conversation_id=${Uri.encodeQueryComponent(resumeId)}'; webSocket = await WebSocket.connect( url, protocols: ['xai-client-secret.$token'], ); // Protocol-level keepalive for mobile networks/NATs. dart:io handles // pong frames automatically and reports a dead socket through onDone. webSocket.pingInterval = const Duration(seconds: 25); if (_isStopping || !mounted || generation != _connectionGeneration) { await webSocket.close(); _isConnecting = false; return; } final channel = IOWebSocketChannel(webSocket); _ws = channel; _isConnecting = false; _wsSub = channel.stream.listen( _handleServerMessage, onError: (e) { debugPrint('❌ WS error: $e'); _handleSocketDisconnected(generation, webSocket!, error: e); }, onDone: () { _handleSocketDisconnected(generation, webSocket!); }, ); if (mounted) { setState(() { _statusText = 'پیکربندی نشست...'; }); } // session.update — اولین پیام بعد از open _send({ 'type': 'session.update', 'session': { 'voice': _voice, 'instructions': _instructions, 'turn_detection': { 'type': 'server_vad', // Keep this below the previous 0.92: that value rejected normal // speech on many phone microphones. Local echo protection still // prevents the assistant audio from being submitted as a turn. 'threshold': 0.5, // Retain enough audio before VAD activation to recover the first // word when a user begins softly. 'prefix_padding_ms': 700, 'silence_duration_ms': 700, }, 'resumption': {'enabled': true}, 'tools': [ { 'type': 'web_search', 'location': { 'country': 'IR', 'city': 'Tehran', 'timezone': 'Asia/Tehran', }, }, {'type': 'x_search'}, ], 'audio': { 'input': { 'format': {'type': 'audio/pcm', 'rate': _sampleRate}, 'transcription': { 'model': 'grok-transcribe', // Current xAI Voice Agent API expects a BCP-47 hint here. // The old top-level input_audio_transcription/language fields // were ignored, leaving short Persian phrases to auto-detect. 'language_hint': 'fa-IR', }, }, 'output': { 'format': {'type': 'audio/pcm', 'rate': _sampleRate}, }, }, }, }); // mic capture را موازی شروع می‌کنیم — قبل از session.updated بافر // می‌شود تا اولین کلمات کاربر گم نشود. if (!_isRecording) { await _startMicCapture(); } } catch (e) { _isConnecting = false; try { await webSocket?.close(); } catch (_) {} debugPrint('❌ Failed to connect xAI: $e'); if (mounted) { setState(() => _statusText = 'اتصال ناموفق — تلاش مجدد...'); } if (_conversationId != null && _reconnectAttempt >= 2) { debugPrint('⚠️ resumption failed repeatedly; starting a fresh session'); _conversationId = null; } _scheduleReconnect(); } } void _handleSocketDisconnected( int generation, WebSocket socket, { Object? error, }) { if (_isStopping || generation != _connectionGeneration || _lastDisconnectedGeneration == generation) { return; } _lastDisconnectedGeneration = generation; _isConnecting = false; _isSessionReady = false; _ws = null; _wsSub = null; final closeCode = socket.closeCode; final closeReason = socket.closeReason; debugPrint( '🔌 WS closed code=${closeCode ?? '-'} reason=${closeReason ?? '-'}' '${error == null ? '' : ' error=$error'}', ); // The server can disconnect before response.done. Let already-queued local // audio finish, then unlock the mic instead of leaving the dialog stuck. if (_isAiSpeaking || _currentResponseId != null) { _serverAudioDone = true; _schedulePlaybackCompletion(_playerGeneration); } if (mounted) { setState(() { _isUserSpeaking = false; _statusText = 'اتصال قطع شد — تلاش مجدد...'; }); } _scheduleReconnect(); } void _scheduleReconnect() { if (_isStopping || !mounted || _reconnectTimer?.isActive == true) return; final exponent = math.min(_reconnectAttempt, 4).toInt(); final delaySeconds = math.min(1 << exponent, 15).toInt(); _reconnectAttempt++; debugPrint('🔄 reconnect scheduled in ${delaySeconds}s'); _reconnectTimer = Timer(Duration(seconds: delaySeconds), () { _reconnectTimer = null; _connectXai(); }); } void _send(Map message) { try { _ws?.sink.add(jsonEncode(message)); } catch (e) { debugPrint('❌ WS send failed: $e'); } } void _lockUserMic() { _unlockUserMicTimer?.cancel(); if (!_isUserAudioLocked) { if (mounted) { setState(() => _isUserAudioLocked = true); } else { _isUserAudioLocked = true; } } } /// Immediate unlock — used at real end-of-turn (response.done, /// interrupt, dispose) so a cancelled unlock timer can never leave the /// mic permanently frozen. Silent no-op when already unlocked. void _unlockUserMicNow() { _unlockUserMicTimer?.cancel(); if (!_isUserAudioLocked) return; if (mounted) { setState(() => _isUserAudioLocked = false); } else { _isUserAudioLocked = false; } } void _hardResetPlayback() { _playbackPumpTimer?.cancel(); _playbackCompletionTimer?.cancel(); _pendingPlaybackChunks.clear(); _playbackReferences.clear(); _estimatedPlaybackEnd = null; _serverAudioDone = false; _playbackSegmentSealed = false; _playbackSegmentStarted = false; _pendingPlaybackSampleCount = 0; _responseLifecycleDone = false; if (!_isPlayerInitialized) return; try { _audioPlayer.uninit(); final initResult = _audioPlayer.init( channels: 1, sampleRate: _sampleRate, bufferMilliSec: _playerBufferMillis, waitingBufferMilliSec: _nativeRecoveryBufferMilliseconds, ); if (initResult != 0) { _isPlayerInitialized = false; throw StateError('mp_audio_stream re-init failed: $initResult'); } _audioPlayer.resume(); } catch (e) { debugPrint('❌ hard reset playback 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 'conversation.created': final conversationId = event['conversation']?['id']?.toString(); if (conversationId != null && conversationId.isNotEmpty) { _conversationId = conversationId; debugPrint('🧵 conversation ready for resumption: $conversationId'); } break; case 'session.updated': debugPrint('🟢 session.updated — flushing mic buffer'); _isSessionReady = true; _reconnectAttempt = 0; // flush buffered audio if (!_isMicMuted) { for (final chunk in _micBufferBeforeReady) { _send({ 'type': 'input_audio_buffer.append', 'audio': base64Encode(chunk), }); } } _micBufferBeforeReady.clear(); _micBufferedBytes = 0; _startMicHealthWatchdog(); if (mounted) { setState(() => _statusText = 'گوش می‌دهم...'); } break; case 'input_audio_buffer.speech_started': if (_isMicMuted) { debugPrint('🔇 speech_started ignored while microphone is muted'); _send({'type': 'input_audio_buffer.clear'}); break; } if (_speakerLikelyAudible) { // The client KNOWS the speaker may still be emitting assistant // audio, so whatever the server VAD just heard cannot be trusted: // mic audio is locally gated in this window, meaning this event // was triggered by echo/in-flight residue, not the user. Purge it // and keep the response alive — the local probe detector is the // only trusted in-playback interrupt path. debugPrint('🔁 speech_started while speaker audible — ' 'treating as echo, clearing server buffer'); _send({'type': 'input_audio_buffer.clear'}); break; } // Server VAD heard the user while the speaker was definitely // silent — this IS a trusted barge-in signal. Cut local playback // instantly; the server handles cancelling generation on its side. debugPrint('🎤 user speech_started'); if (_assistantOwnsAudioTurn) { debugPrint('✂️ user spoke during assistant turn — interrupting'); _interruptAi( reason: 'server_vad_speech_started', // Do NOT clear the input buffer — it holds the very speech // that triggered this event. clearInputBuffer: false, keepMicOpen: true, ); } if (mounted) setState(() => _isUserSpeaking = true); break; case 'input_audio_buffer.speech_stopped': if (mounted) setState(() => _isUserSpeaking = false); break; case 'conversation.item.input_audio_transcription.updated': final transcript = event['transcript']?.toString() ?? ''; if (transcript.isNotEmpty) { debugPrint('📝 USER (live): $transcript'); } break; case 'conversation.item.input_audio_transcription.completed': final transcript = event['transcript']?.toString() ?? ''; if (transcript.isNotEmpty) { debugPrint('📝 USER (final): $transcript'); } break; case 'response.created': // A new server response must be appended after already-buffered audio. // Resetting the player here cuts the previous sentence (especially // around built-in web-search/tool turns). _pcmCarryByte = null; _currentResponseId = event['response']?['id']?.toString() ?? event['response_id']?.toString(); _finishedResponseId = null; _serverAudioDone = false; _playbackSegmentSealed = false; _playbackSegmentStarted = false; _pendingPlaybackSampleCount = 0; _responseLifecycleDone = false; _audioDeltasReceived = 0; _audioSamplesQueued = 0; // Restart the turn-watchdog idle clock for THIS response — // without this, the stamp from the previous turn makes the // watchdog think the new response is already 10s+ idle and it // kills it before the first audio delta arrives. _lastAssistantAudioAt = DateTime.now(); _playbackCompletionTimer?.cancel(); _resetBargeInDetector(); _lockUserMic(); if (_liveAiTranscript.trim().isNotEmpty) { _commitAiTranscript(); } if (mounted) { setState(() { _isAiSpeaking = true; _statusText = 'در حال پاسخ...'; }); } _unlockUserMicTimer?.cancel(); break; case 'response.output_audio.delta': final deltaResponseId = event['response_id']?.toString(); // xAI normally includes response_id. Accept an id-less delta only // while one response is active, but always reject an explicit stale id. if (_currentResponseId == null || (deltaResponseId != null && deltaResponseId != _currentResponseId)) { break; } final delta = event['delta']?.toString(); if (delta != null && delta.isNotEmpty) { // A built-in web/X search may split one response into multiple // spoken segments. output_audio.done ends the first segment, then // more deltas with the same response id arrive after the tool. _serverAudioDone = false; _playbackSegmentSealed = false; _finishedResponseId = null; _playbackCompletionTimer?.cancel(); _audioDeltasReceived++; _lastAssistantAudioAt = DateTime.now(); _allowBargeInAfter ??= DateTime.now().add( const Duration(milliseconds: _bargeInArmDelayMilliseconds)); _playPcmChunk(delta, _playerGeneration); if (_audioDeltasReceived == 1 || _audioDeltasReceived % 25 == 0) { debugPrint( '🔊 audio delta accepted: count=$_audioDeltasReceived ' 'response=${deltaResponseId ?? _currentResponseId}', ); } if (mounted && !_isAiSpeaking) { setState(() { _isAiSpeaking = true; }); } _lockUserMic(); _unlockUserMicTimer?.cancel(); } break; case 'response.output_audio.done': final doneResponseId = event['response_id']?.toString(); if (_currentResponseId == null || (doneResponseId != null && doneResponseId != _currentResponseId)) { break; } final completedResponseId = doneResponseId ?? _currentResponseId; if (_finishedResponseId == completedResponseId) { break; } _finishedResponseId = completedResponseId; _serverAudioDone = true; _sealPlaybackSegment(_playerGeneration); _pumpPlaybackQueue(); _schedulePlaybackCompletion(_playerGeneration); break; case 'response.output_audio_transcript.delta': final delta = event['delta']?.toString() ?? ''; if (delta.isNotEmpty) { debugPrint('💬 AI: $delta'); _appendAiTranscript(delta); } break; case 'response.output_audio_transcript.done': final transcript = event['transcript']?.toString() ?? ''; _commitAiTranscript(transcript); break; case 'response.output_item.added': final item = event['item']; final itemJson = item == null ? '' : jsonEncode(item).toLowerCase(); if (itemJson.contains('web_search') || itemJson.contains('x_search')) { debugPrint('🔎 server-side search started: $itemJson'); if (mounted) { setState(() => _statusText = 'در حال جست‌وجوی وب...'); } } else if (kDebugMode) { debugPrint('🧪 output item added: ${jsonEncode(event)}'); } break; case 'response.output_item.done': final item = event['item']; final itemJson = item == null ? '' : jsonEncode(item).toLowerCase(); if (itemJson.contains('web_search') || itemJson.contains('x_search')) { debugPrint('✅ server-side search completed'); if (mounted) { setState(() => _statusText = 'در حال آماده‌سازی پاسخ...'); } } else if (kDebugMode) { debugPrint('🧪 output item done: ${jsonEncode(event)}'); } break; case 'response.content_part.done': case 'response.transcript.done': case 'response.function_call': case 'response.function_call.done': if (kDebugMode) { debugPrint( '🧪 web search event: $type payload=${jsonEncode(event)}'); } break; case 'response.done': debugPrint('✅ response.done'); final doneResponseId = event['response']?['id']?.toString(); if (_currentResponseId != null && doneResponseId != null && doneResponseId != _currentResponseId) { break; } _finishedResponseId ??= doneResponseId ?? _currentResponseId; _serverAudioDone = true; _sealPlaybackSegment(_playerGeneration); _pumpPlaybackQueue(); _responseLifecycleDone = true; _commitAiTranscript(); _schedulePlaybackCompletion(_playerGeneration); break; case 'error': final nestedError = event['error']; final errorDetails = nestedError is Map ? Map.from(nestedError) : const {}; final msg = errorDetails['message']?.toString() ?? event['message']?.toString() ?? nestedError?.toString() ?? 'unknown'; final code = errorDetails['code']?.toString() ?? event['code']?.toString() ?? 'unknown_code'; final errorType = errorDetails['type']?.toString() ?? event['error_type']?.toString() ?? 'unknown_type'; final normalizedMessage = msg.toLowerCase(); final isBenignCancelRace = normalizedMessage.contains('no active response') || normalizedMessage.contains('not active'); if (isBenignCancelRace) { debugPrint('ℹ️ response.cancel race ignored: $msg'); break; } debugPrint('⚠️ xAI error [$errorType/$code]: $msg'); if (kDebugMode) { debugPrint('⚠️ xAI error payload=${jsonEncode(event)}'); } if (mounted) { setState(() => _statusText = 'خطا: $msg'); } break; default: // many other events — only verbose log if (kDebugMode) debugPrint('📨 $type'); } } catch (e) { debugPrint('❌ parse message error: $e'); } } // Currently-accepted xAI response id. xAI can keep emitting audio deltas // from a cancelled response for a moment after response.cancel, and they // interleave with the new response's deltas → words play out of order. // We only push deltas whose event.response_id matches this id. String? _currentResponseId; String? _finishedResponseId; /// True while the speaker is (or may still be) emitting assistant audio. /// Derived from the playback-end estimate plus a fixed hardware/echo tail, /// AND from the echo cooldown, which covers the physical tail after /// playback drained or was cut. Mic bytes must never reach the server /// while this is true — the server VAD transcribes even near-silent echo /// as user speech and the assistant ends up answering itself. bool get _speakerLikelyAudible { final cooldown = _echoCooldownUntil; if (cooldown != null && DateTime.now().isBefore(cooldown)) return true; final end = _estimatedPlaybackEnd; if (end == null) return _pendingPlaybackChunks.isNotEmpty; return DateTime.now().isBefore(end.add(const Duration(milliseconds: 400))); } bool get _assistantOwnsAudioTurn { if (_isAiSpeaking || _currentResponseId != null) return true; if (_pendingPlaybackChunks.isNotEmpty) return true; final playbackEnd = _estimatedPlaybackEnd; return playbackEnd != null && playbackEnd.isAfter(DateTime.now()); } // Single-byte remainder carried across chunks if a chunk has odd length // (rare but happens on some network split points). Without carrying it, // the next chunk's first byte pairs with the wrong high byte and every // sample from that point on is garbled. int? _pcmCarryByte; /// PCM 16-bit signed LE chunk از base64 → convert to Float32 and push /// into mp_audio_stream's ring buffer. Uses ByteData for explicit LE /// reads (safer than Int16List.view against unaligned buffers). void _playPcmChunk(String base64Chunk, int generation) { if (!_isPlayerInitialized || generation != _playerGeneration) return; try { final decoded = base64Decode(base64Chunk); // Prepend any carry-over odd byte from the previous chunk. Uint8List bytes; if (_pcmCarryByte != null) { bytes = Uint8List(decoded.length + 1) ..[0] = _pcmCarryByte! ..setRange(1, decoded.length + 1, decoded); _pcmCarryByte = null; } else { bytes = decoded; } // If the total is odd, keep the trailing byte for the next chunk. final usableLen = bytes.length - (bytes.length & 1); if (usableLen < bytes.length) { _pcmCarryByte = bytes[bytes.length - 1]; } if (usableLen == 0) return; final view = ByteData.sublistView(bytes, 0, usableLen); final sampleCount = usableLen ~/ 2; final samples = Float32List(sampleCount); var sumSquares = 0.0; var sumDifferenceSquares = 0.0; var previousSample = 0.0; for (var i = 0; i < sampleCount; i++) { final normalized = (view.getInt16(i * 2, Endian.little) / 32768.0) * _playbackGain; final sample = normalized.clamp(-1.0, 1.0).toDouble(); samples[i] = sample; sumSquares += sample * sample; if (i > 0) { final difference = sample - previousSample; sumDifferenceSquares += difference * difference; } previousSample = sample; } final rms = math.sqrt(sumSquares / sampleCount); final differenceRms = math.sqrt(sumDifferenceSquares / math.max(1, sampleCount - 1)); _aiAudioLevel = math.max(_aiAudioLevel * 0.62, (rms * 4.2).clamp(0, 1)); _aiVoiceTexture = math.max( _aiVoiceTexture * 0.66, (differenceRms * 3.4).clamp(0, 1), ); if (!_isPlayerInitialized || generation != _playerGeneration) return; _audioSamplesQueued += sampleCount; _enqueuePlaybackSamples(samples, generation); } catch (e) { debugPrint('❌ playPcmChunk error: $e'); } } void _enqueuePlaybackSamples(Float32List samples, int generation) { if (!_isPlayerInitialized || generation != _playerGeneration) return; final now = DateTime.now(); final tail = _estimatedPlaybackEnd; final startsAt = tail != null && tail.isAfter(now) ? tail : now.add( const Duration(milliseconds: _initialJitterBufferMilliseconds), ); final durationMicros = samples.length * Duration.microsecondsPerSecond ~/ _sampleRate; _estimatedPlaybackEnd = startsAt.add(Duration(microseconds: durationMicros)); _playbackReferences.add( _PlaybackReferenceChunk( samples: samples, startsAt: startsAt, endsAt: _estimatedPlaybackEnd!, ), ); final staleBefore = now.subtract(const Duration(seconds: 1)); while (_playbackReferences.isNotEmpty && _playbackReferences.first.endsAt.isBefore(staleBefore)) { _playbackReferences.removeFirst(); } _pendingPlaybackChunks.add(_QueuedPcmChunk(samples, generation)); _pendingPlaybackSampleCount += samples.length; _pumpPlaybackQueue(); // Push echo-cooldown further into the future on every chunk we hand // to the player — anchored to when this chunk will actually finish // playing out of the speaker, plus a safety margin for the echo tail. final projected = _estimatedPlaybackEnd! .add(const Duration(milliseconds: _echoCooldownAfterChunkMs)); if (_echoCooldownUntil == null || projected.isAfter(_echoCooldownUntil!)) { _echoCooldownUntil = projected; } } /// A tiny silent marker guarantees the speech ahead of it drains even when /// the final server delta is shorter than the native recovery threshold. void _sealPlaybackSegment(int generation) { if (_playbackSegmentSealed || !_isPlayerInitialized || generation != _playerGeneration) { return; } _playbackSegmentSealed = true; const paddingSamples = _sampleRate * _playbackDrainPaddingMilliseconds ~/ Duration.millisecondsPerSecond; _enqueuePlaybackSamples(Float32List(paddingSamples), generation); final stat = _audioPlayer.stat(); debugPrint( '🧩 playback segment sealed ' '(padding=${_playbackDrainPaddingMilliseconds}ms ' 'exhaust=${stat.exhaust} full=${stat.full})', ); } /// mp_audio_stream drops a whole chunk when its native ring buffer is full. /// Keep failed pushes in an ordered Dart queue and retry after the device has /// consumed a little audio, so no word or phrase silently disappears. void _pumpPlaybackQueue() { _playbackPumpTimer?.cancel(); _playbackPumpTimer = null; if (!_playbackSegmentStarted) { const initialSamples = _sampleRate * _initialJitterBufferMilliseconds ~/ Duration.millisecondsPerSecond; if (!_serverAudioDone && _pendingPlaybackSampleCount < initialSamples) { return; } _playbackSegmentStarted = true; debugPrint( '▶️ playback started after jitter buffer ' '(samples=$_pendingPlaybackSampleCount)', ); } while (_pendingPlaybackChunks.isNotEmpty) { final chunk = _pendingPlaybackChunks.first; if (chunk.generation != _playerGeneration) { _pendingPlaybackSampleCount = math.max(0, _pendingPlaybackSampleCount - chunk.samples.length); _pendingPlaybackChunks.removeFirst(); continue; } if (!_isPlayerInitialized) return; final result = _audioPlayer.push(chunk.samples); if (result != 0) { final stat = _audioPlayer.stat(); debugPrint( '⏳ player buffer full; retrying ' '(queued=${_pendingPlaybackChunks.length} ' 'full=${stat.full} exhaust=${stat.exhaust})', ); _playbackPumpTimer = Timer( const Duration(milliseconds: 25), _pumpPlaybackQueue, ); return; } _pendingPlaybackSampleCount = math.max(0, _pendingPlaybackSampleCount - chunk.samples.length); _pendingPlaybackChunks.removeFirst(); } if (_serverAudioDone) { _schedulePlaybackCompletion(_playerGeneration); } } void _schedulePlaybackCompletion(int generation) { if (!_serverAudioDone || generation != _playerGeneration) return; _playbackCompletionTimer?.cancel(); final now = DateTime.now(); final estimatedEnd = _estimatedPlaybackEnd ?? now; final safeEnd = estimatedEnd.add(const Duration(milliseconds: 300)); final delay = safeEnd.isAfter(now) ? safeEnd.difference(now) : const Duration(milliseconds: 50); _playbackCompletionTimer = Timer(delay, () { if (generation != _playerGeneration || !_serverAudioDone) return; if (_pendingPlaybackChunks.isNotEmpty) { _pumpPlaybackQueue(); _schedulePlaybackCompletion(generation); return; } final end = _estimatedPlaybackEnd; if (end != null && end.isAfter(DateTime.now())) { _schedulePlaybackCompletion(generation); return; } _finishPlaybackTurn(generation); }); } void _finishPlaybackTurn(int generation) { if (generation != _playerGeneration) return; _playbackCompletionTimer?.cancel(); // Do not discard the response id merely because one audio segment ended. // Built-in web_search/x_search can pause for seconds and then continue // streaming audio inside the same response lifecycle. if (_responseLifecycleDone) { _currentResponseId = null; } _pcmCarryByte = null; _estimatedPlaybackEnd = null; _serverAudioDone = false; _playbackSegmentSealed = false; _playbackSegmentStarted = false; _pendingPlaybackSampleCount = 0; if (_responseLifecycleDone) { debugPrint( '🏁 playback complete: deltas=$_audioDeltasReceived ' 'samples=$_audioSamplesQueued', ); _responseLifecycleDone = false; } else { debugPrint('⏸️ audio segment drained; keeping response active for tool'); } _playbackReferences.clear(); _resetBargeInDetector(); // The native/output device can still emit samples after Dart's estimated // queue end. Keep that physical tail entirely out of server VAD. Local // barge-in remains available while actual assistant audio is playing. _echoCooldownUntil = DateTime.now().add( const Duration(milliseconds: _naturalPlaybackEchoTailMilliseconds), ); _unlockUserMicNow(); if (mounted) { setState(() { _isAiSpeaking = false; _statusText = _isSessionReady ? 'گوش می‌دهم...' : 'در حال اتصال مجدد...'; }); } } void _interruptAi({ required String reason, bool clearInputBuffer = false, // When true, skip the 300ms post-interrupt mini-cooldown. Callers that // are ABOUT to send freshly-captured user audio (local barge-in with a // pre-roll queue) need the mic unblocked immediately, otherwise the // pre-roll gets swallowed by cooldown and xAI never sees the start of // the user's sentence. bool keepMicOpen = false, }) { final isAutomaticVoiceInterrupt = reason == 'confirmed_local_voice'; final isBufferedPlaybackTail = _serverAudioDone || _responseLifecycleDone || _currentResponseId == null; if (isAutomaticVoiceInterrupt && isBufferedPlaybackTail) { _resetBargeInDetector(); debugPrint( '🛡️ stale automatic interrupt blocked: ' 'response=${_currentResponseId ?? 'none'} ' 'serverAudioDone=$_serverAudioDone ' 'responseDone=$_responseLifecycleDone', ); return; } _commitAiTranscript(); // response.done may already have arrived while local audio is still queued. // In that state only stop local playback; response.cancel would be invalid. final hadActiveServerResponse = _currentResponseId != null && !_serverAudioDone && !_responseLifecycleDone; debugPrint( '🛑 AI interrupt executing: reason=$reason ' 'response=${_currentResponseId ?? 'none'} ' 'serverActive=$hadActiveServerResponse ' 'queued=${_pendingPlaybackChunks.length}', ); _unlockUserMicTimer?.cancel(); // Clear echo cooldown immediately — the AI is being cut off NOW, so // the estimated playback end is stale. Without this the mic bytes // stay blocked for the full remaining projected duration and the // user's next words never reach xAI. _echoCooldownUntil = null; if (mounted) { setState(() => _isUserAudioLocked = false); } // Bump generation (drops any in-flight _playPcmChunk), clear PCM // remainder, and fully drain the ring buffer. resetStat() only touches // stats — the buffer must be uninit+reinit'd to actually stop old audio // from playing through and interleaving with the next response. _playerGeneration++; _pcmCarryByte = null; // Invalidate the current response id — any lingering deltas from the // cancelled response will be filtered out by response.output_audio.delta. _currentResponseId = null; _finishedResponseId = null; _responseLifecycleDone = false; _resetBargeInDetector(); _hardResetPlayback(); if (clearInputBuffer) { _send({'type': 'input_audio_buffer.clear'}); } // 2) به xAI بگو response را cancel کن if (hadActiveServerResponse) { _send({'type': 'response.cancel'}); } if (mounted) { setState(() { _isAiSpeaking = false; _statusText = _isSessionReady ? 'گوش می‌دهم...' : 'در حال اتصال مجدد...'; }); } // Short mini-cooldown so the last ~300ms of AI audio already in the // hardware buffer doesn't leak back as user speech. Much shorter than // the natural end-of-turn cooldown because the AI is silent from here. // Skipped when a caller is about to push fresh mic audio itself. if (!keepMicOpen) { _echoCooldownUntil = DateTime.now().add(const Duration(milliseconds: 300)); } } /// Fired when the user taps the sphere. One reliable action regardless /// of internal state: kill any AI playback + server response, clear the /// echo cooldown so the mic goes live now, and give a brief visual cue. /// This is the guaranteed "I want to talk" path. void _handleUserTakeover() { debugPrint('👆 user takeover — interrupt + open mic'); _interruptAi( reason: 'user_tap', clearInputBuffer: true, keepMicOpen: true, ); _echoCooldownUntil = null; if (mounted) { setState(() { _isMicMuted = false; _isAiSpeaking = false; _statusText = 'گوش می‌دهم...'; }); } } void _toggleMicrophone() { final shouldMute = !_isMicMuted; if (shouldMute) { _send({'type': 'input_audio_buffer.clear'}); _micBufferBeforeReady.clear(); _micBufferedBytes = 0; _isUserSpeaking = false; _userAudioLevel = 0; } else { _echoCooldownUntil = null; } setState(() { _isMicMuted = shouldMute; _statusText = shouldMute ? 'میکروفن خاموش است' : 'گوش می‌دهم...'; }); } Future _closeVoiceChat() async { await _stopAll(); if (mounted) Navigator.of(context).pop(); } int _bargeInQuietRun = 0; void _resetBargeInDetector() { _bargeInProbeTimer?.cancel(); _bargeInProbeTimer = null; _bargeInProbeActive = false; _bargeInProbeEvaluateAfter = null; _bargeInProbeVoiceChunks = 0; _bargeInProbePeakRms = 0; _bargeInLoudChunks = 0; _bargeInLoudSamples = 0; _bargeInQuietRun = 0; _echoRmsBaseline = 0; _allowBargeInAfter = null; _tailBargeInLogSkip = 0; _lastBargeInTriggerRms = 0; _bargeInProbeRejections = 0; } double _pcmRms(Uint8List chunk) { final usableLength = chunk.length - (chunk.length & 1); if (usableLength == 0) return 0; final data = ByteData.sublistView(chunk, 0, usableLength); final sampleCount = usableLength ~/ 2; var sumSquares = 0.0; for (var i = 0; i < sampleCount; i++) { final sample = data.getInt16(i * 2, Endian.little) / 32768.0; sumSquares += sample * sample; } return math.sqrt(sumSquares / sampleCount); } double _pcmTexture(Uint8List chunk) { final usableLength = chunk.length - (chunk.length & 1); if (usableLength < 4) return 0; final data = ByteData.sublistView(chunk, 0, usableLength); final sampleCount = usableLength ~/ 2; var previous = data.getInt16(0, Endian.little) / 32768.0; var sumDifferenceSquares = 0.0; for (var i = 1; i < sampleCount; i++) { final current = data.getInt16(i * 2, Endian.little) / 32768.0; final difference = current - previous; sumDifferenceSquares += difference * difference; previous = current; } return math.sqrt(sumDifferenceSquares / (sampleCount - 1)); } Future _beginBargeInProbe() async { if (_bargeInProbeActive || !_assistantOwnsAudioTurn || _isStopping) return; _bargeInProbeActive = true; _bargeInProbeVoiceChunks = 0; _bargeInProbePeakRms = 0; _bargeInProbeEvaluateAfter = DateTime.now().add( const Duration(milliseconds: _bargeInProbeEchoTailMilliseconds), ); debugPrint('🔇 barge-in probe started — ducking speaker output'); try { await _audioEventsChannel.invokeMethod('beginOutputProbe'); } catch (error) { debugPrint('⚠️ barge-in probe unavailable: $error'); _bargeInProbeActive = false; _bargeInProbeEvaluateAfter = null; _bargeInLoudChunks = 0; _bargeInLoudSamples = 0; return; } _bargeInProbeTimer?.cancel(); _bargeInProbeTimer = Timer( const Duration(milliseconds: _bargeInProbeDurationMilliseconds), () => unawaited(_finishBargeInProbe()), ); } void _handleBargeInProbeChunk(double rms) { final evaluateAfter = _bargeInProbeEvaluateAfter; if (evaluateAfter == null || DateTime.now().isBefore(evaluateAfter)) return; _bargeInProbePeakRms = math.max(_bargeInProbePeakRms, rms); if (rms >= _bargeInProbeVoiceFloor) { _bargeInProbeVoiceChunks++; } } Future _finishBargeInProbe() async { if (!_bargeInProbeActive) return; _bargeInProbeTimer?.cancel(); _bargeInProbeTimer = null; final confirmed = _bargeInProbeVoiceChunks >= _bargeInProbeRequiredVoiceChunks; final voiceChunks = _bargeInProbeVoiceChunks; final peakRms = _bargeInProbePeakRms; try { await _audioEventsChannel.invokeMethod('endOutputProbe'); } catch (error) { debugPrint('⚠️ speaker volume restore failed: $error'); } _bargeInProbeActive = false; _bargeInProbeEvaluateAfter = null; _bargeInProbeVoiceChunks = 0; _bargeInProbePeakRms = 0; if (!confirmed || !_assistantOwnsAudioTurn) { _bargeInProbeRejections++; // The energy that triggered this probe was just proven to be echo. // Lift the learned echo baseline above it so the same echo cannot // immediately retrigger a probe, and back off exponentially while // rejections keep coming — every false probe is a ~460ms speaker // mute the user hears as skipped words. _echoRmsBaseline = math.max(_echoRmsBaseline, _lastBargeInTriggerRms); final suppressionMs = math.min(300 * (1 << _bargeInProbeRejections), 2400); debugPrint( '🔁 barge-in probe rejected as speaker echo ' '(voiceChunks=$voiceChunks peak=${peakRms.toStringAsFixed(3)} ' 'trigger=${_lastBargeInTriggerRms.toStringAsFixed(3)} ' 'baseline→${_echoRmsBaseline.toStringAsFixed(3)} ' 'suppress=${suppressionMs}ms)', ); _bargeInLoudChunks = 0; _bargeInLoudSamples = 0; _bargeInQuietRun = 0; _allowBargeInAfter = DateTime.now().add( Duration(milliseconds: suppressionMs), ); return; } _bargeInProbeRejections = 0; debugPrint( '🎤 barge-in probe confirmed user voice ' '(voiceChunks=$voiceChunks peak=${peakRms.toStringAsFixed(3)})', ); _interruptAi( reason: 'confirmed_local_voice', clearInputBuffer: true, keepMicOpen: true, ); _echoCooldownUntil = DateTime.now().add( const Duration(milliseconds: _postInterruptEchoTailMilliseconds), ); } /// Compares microphone PCM with the exact PCM currently scheduled on the /// speaker. Android AEC changes volume and may invert polarity, so Pearson /// correlation is checked across a range of realistic speaker-to-mic delays. /// A high score means the mic heard Houshan, not a new user utterance. double _playbackEchoCorrelation(Uint8List chunk) { if (_playbackReferences.isEmpty) return 0; final usableLength = chunk.length - (chunk.length & 1); if (usableLength < 160) return 0; final micData = ByteData.sublistView(chunk, 0, usableLength); final micSampleCount = usableLength ~/ 2; final micDurationUs = micSampleCount * Duration.microsecondsPerSecond ~/ _sampleRate; final micStartUs = DateTime.now().microsecondsSinceEpoch - micDurationUs; final references = _playbackReferences.toList(growable: false); var bestCorrelation = 0.0; // Search 0–700 ms in 5 ms steps. Phone speakers, Bluetooth routes and OEM // AECs expose very different latency; the old 20 ms grid could miss the // same waveform after a route/gain adjustment. Downsampling keeps this // bounded even with the finer delay grid. for (var delayMs = 0; delayMs <= 700; delayMs += 5) { var referenceIndex = 0; var count = 0; var sumMic = 0.0; var sumReference = 0.0; var sumMicSquared = 0.0; var sumReferenceSquared = 0.0; var sumProduct = 0.0; for (var i = 0; i < micSampleCount; i += 8) { final sourceUs = micStartUs + (i * Duration.microsecondsPerSecond ~/ _sampleRate) - (delayMs * Duration.microsecondsPerMillisecond); while (referenceIndex < references.length && sourceUs > references[referenceIndex].endsAt.microsecondsSinceEpoch) { referenceIndex++; } if (referenceIndex >= references.length) break; final reference = references[referenceIndex]; final referenceStartUs = reference.startsAt.microsecondsSinceEpoch; if (sourceUs < referenceStartUs) continue; final sourceSample = (sourceUs - referenceStartUs) * _sampleRate ~/ Duration.microsecondsPerSecond; if (sourceSample < 0 || sourceSample >= reference.samples.length) { continue; } final mic = micData.getInt16(i * 2, Endian.little) / 32768.0; final output = reference.samples[sourceSample]; count++; sumMic += mic; sumReference += output; sumMicSquared += mic * mic; sumReferenceSquared += output * output; sumProduct += mic * output; } if (count < 24) continue; final covariance = sumProduct - ((sumMic * sumReference) / count); final micVariance = sumMicSquared - ((sumMic * sumMic) / count); final referenceVariance = sumReferenceSquared - ((sumReference * sumReference) / count); if (micVariance <= 0.0000001 || referenceVariance <= 0.0000001) { continue; } final correlation = (covariance / math.sqrt(micVariance * referenceVariance)).abs(); bestCorrelation = math.max(bestCorrelation, correlation); } return bestCorrelation; } void _handlePossibleBargeIn(Uint8List chunk) { // The mic lock prevents audio from being submitted to the server; it must // not disable this local detector. During assistant playback this method // is the only path capable of noticing that the user wants to interrupt. // // Once output_audio.done/response.done arrives, only already-buffered // native audio remains. mp_audio_stream underruns can shift that tail far // away from the Dart playback references, making speaker echo correlation // unreliable. Never auto-interrupt that tail; explicit tap-to-takeover // remains available and still stops it immediately. if (_serverAudioDone || _responseLifecycleDone || _currentResponseId == null) { _bargeInLoudChunks = 0; _bargeInLoudSamples = 0; if (_tailBargeInLogSkip++ % 20 == 0) { debugPrint( '🛡️ auto barge-in disarmed for buffered playback tail ' '(serverAudioDone=$_serverAudioDone ' 'responseDone=$_responseLifecycleDone)', ); } return; } final rms = _pcmRms(chunk); if (_bargeInProbeActive) { _handleBargeInProbeChunk(rms); return; } final allowedAfter = _allowBargeInAfter; if (allowedAfter == null || DateTime.now().isBefore(allowedAfter)) { // Aggressive baseline learning while barge-in is disarmed — this is // when we KNOW anything the mic picks up is echo. Track the echo PEAK // envelope, not just the mean: speech bursts sit far above the mean // and would otherwise cross the trigger threshold every few syllables. // Each false trigger is a ~460ms speaker mute that eats spoken words. final blended = _echoRmsBaseline == 0 ? rms : (_echoRmsBaseline * 0.7) + (rms * 0.3); _echoRmsBaseline = math.max(blended, rms * 0.9); return; } // Require real user speech above both a low floor and the learned echo // baseline. Threshold = floor OR a modest multiple of the learned echo // baseline. The old extra term (_aiAudioLevel * 0.16) made the bar rise // with the assistant's own loudness, which — combined with AEC crushing // the user's voice — made interrupting physically impossible on real // phones. This is only a PROBE trigger: the final decision is made with // the speaker muted, so a low bar cannot cause a false interrupt — it // can only cause a short, self-correcting duck. final dynamicThreshold = math.max(_bargeInRmsThreshold, _echoRmsBaseline * 1.2); final echoCorrelation = _playbackEchoCorrelation(chunk); final echoLevelCeiling = math.max( _bargeInRmsThreshold * 1.2, _echoRmsBaseline * 1.2, ); final isPlaybackEcho = echoCorrelation >= _strongEchoCorrelation || (echoCorrelation >= _possibleEchoCorrelation && rms <= echoLevelCeiling); if (isPlaybackEcho) { _bargeInLoudChunks = 0; _bargeInLoudSamples = 0; if (rms < _echoRmsBaseline || _echoRmsBaseline == 0) { _echoRmsBaseline = _echoRmsBaseline == 0 ? rms : (_echoRmsBaseline * 0.94) + (rms * 0.06); } if (_echoLogSkip++ % 25 == 0) { debugPrint( '🔁 speaker echo ignored: correlation=' '${echoCorrelation.toStringAsFixed(2)} rms=${rms.toStringAsFixed(3)}', ); } return; } if (rms >= dynamicThreshold) { _bargeInQuietRun = 0; _bargeInLoudChunks++; _bargeInLoudSamples += chunk.length ~/ 2; debugPrint( '🎙️ barge-in candidate: ' 'rms=${rms.toStringAsFixed(3)} >= ' 'threshold=${dynamicThreshold.toStringAsFixed(3)} ' 'chunks=$_bargeInLoudChunks ' 'correlation=${echoCorrelation.toStringAsFixed(2)}', ); final loudMilliseconds = _bargeInLoudSamples * 1000 ~/ _sampleRate; if (_bargeInLoudChunks >= _bargeInProbeTriggerChunks && loudMilliseconds >= _bargeInProbeTriggerMilliseconds) { _lastBargeInTriggerRms = rms; unawaited(_beginBargeInProbe()); return; } } else { if (_rmsLogSkip++ % 20 == 0) { debugPrint( '🛡️ barge-in rejected: ' 'rms=${rms.toStringAsFixed(3)} < ' 'threshold=${dynamicThreshold.toStringAsFixed(3)} ' 'baseline=${_echoRmsBaseline.toStringAsFixed(3)} ' 'correlation=${echoCorrelation.toStringAsFixed(2)}', ); } // Never let the baseline chase a user's gradually-rising first word. // It may fall slowly when the speaker echo becomes quieter, but it // must not rise after barge-in has been armed. if (_echoRmsBaseline == 0) { _echoRmsBaseline = rms; } else if (rms < _echoRmsBaseline) { _echoRmsBaseline = (_echoRmsBaseline * 0.96) + (rms * 0.04); } // Tolerate short dips between syllables — reset the loud counters // only after 2 consecutive quiet chunks (~130ms). Without this, // requiring 6 loud chunks would make natural speech (which dips // below threshold between words) almost impossible to detect. _bargeInQuietRun++; if (_bargeInQuietRun >= 2) { _bargeInLoudChunks = 0; _bargeInLoudSamples = 0; } return; } final loudMilliseconds = _bargeInLoudSamples * 1000 ~/ _sampleRate; if (_bargeInLoudChunks < _bargeInMinLoudChunks || loudMilliseconds < _bargeInMinLoudMilliseconds) { return; } debugPrint('🎤 local barge-in confirmed (rms=${rms.toStringAsFixed(3)} ' 'threshold=${dynamicThreshold.toStringAsFixed(3)} ' 'correlation=${echoCorrelation.toStringAsFixed(2)} ' 'ms=$loudMilliseconds)'); // Never submit pre-roll captured while the speaker was active: it contains // the assistant's own voice and makes the model answer itself. Stop output, // clear the server buffer, then wait only for the hardware echo tail before // forwarding fresh microphone audio. _interruptAi( reason: 'confirmed_local_voice', clearInputBuffer: true, keepMicOpen: true, ); _echoCooldownUntil = DateTime.now().add( const Duration(milliseconds: _postInterruptEchoTailMilliseconds), ); } void _appendMicChunk(Uint8List chunk) { if (_isMicMuted) return; // No echo-cooldown / assistant-turn gating here anymore — full-duplex // streaming relies on device AEC + server VAD (see mic listener). if (_isSessionReady) { _send({ 'type': 'input_audio_buffer.append', 'audio': base64Encode(chunk), }); _micChunksSent++; _lastMicChunkSentAt = DateTime.now(); if (_micChunksSent == 1 || _micChunksSent % 50 == 0) { debugPrint( '📤 mic audio sent: chunks=$_micChunksSent ' 'bytes=${chunk.length} rms=${_pcmRms(chunk).toStringAsFixed(3)}', ); } return; } _micBufferBeforeReady.add(chunk); _micBufferedBytes += chunk.length; while (_micBufferedBytes > _maxPreSessionAudioBytes && _micBufferBeforeReady.isNotEmpty) { _micBufferedBytes -= _micBufferBeforeReady.removeAt(0).length; } } Future _startMicCapture() async { if (_isRecording || _isStartingRecorder || _isStopping) return; _isStartingRecorder = true; try { if (!await _audioRecorder.hasPermission()) { debugPrint('❌ mic permission denied'); if (mounted) setState(() => _statusText = 'دسترسی به میکروفون رد شد'); return; } if (!mounted || _isStopping) return; final stream = await _audioRecorder.startStream(const RecordConfig( encoder: AudioEncoder.pcm16bits, sampleRate: _sampleRate, numChannels: 1, // Playback is intentional and must not pause the microphone. Barge-in // depends on receiving mic chunks while the assistant is speaking. audioInterruption: AudioInterruptionMode.none, echoCancel: true, noiseSuppress: true, // AGC amplifies speaker bleed during full-duplex playback and was the // main reason local echo crossed the barge-in threshold. autoGain: false, streamBufferSize: 3200, androidConfig: AndroidRecordConfig( // VOICE_COMMUNICATION returns near-silent PCM on some OEM devices // when the app also owns ASSISTANT audio focus. Use the universally // available MIC source and keep explicit platform AEC/NS enabled // above; modeNormal also preserves the assistant/media output route. audioSource: AndroidAudioSource.mic, audioManagerMode: AudioManagerMode.modeNormal, speakerphone: false, ), )); if (!mounted || _isStopping) { await _audioRecorder.stop(); return; } _isRecording = true; _lastMicChunkAt = DateTime.now(); _audioStreamSubscription = stream.listen((chunk) { _lastMicChunkAt = DateTime.now(); _micChunksReceived++; if (_isMicMuted) { _userAudioLevel = 0; return; } _userAudioLevel = math.max(_userAudioLevel * 0.55, (_pcmRms(chunk) * 7).clamp(0, 1)); if (!_assistantOwnsAudioTurn) { _userVoiceTexture = math.max( _userVoiceTexture * 0.62, (_pcmTexture(chunk) * 4.6).clamp(0, 1), ); } // Wall-clock speaker gate. Empirically the server transcribes even // 0.006-RMS speaker echo as user speech (full-duplex experiment: // the AI answered its own sentences verbatim), so mic audio must // not reach the server while the speaker is audibly playing. The // gate is a pure wall-clock window (playback estimate + tail) — // unlike the old event-driven locks it CANNOT deadlock: worst case // it expires by itself ≤400ms after audio ends. While gated, the // local correlation-filtered barge-in detector is the voice // interrupt path; tap-to-interrupt clears the window instantly. if (_speakerLikelyAudible) { _handlePossibleBargeIn(chunk); return; } _appendMicChunk(chunk); }, onError: (Object error, StackTrace stackTrace) { debugPrint('❌ mic stream error: $error'); unawaited(_restartMicCapture('stream error')); }, onDone: () { debugPrint('⚠️ mic stream ended unexpectedly'); unawaited(_restartMicCapture('stream ended')); }); debugPrint( '🎙️ mic capture started @ ${_sampleRate}Hz PCM16 source=mic AEC=on NS=on', ); _startMicHealthWatchdog(); if (mounted && !_isSessionReady) { setState(() => _statusText = 'میکروفن آماده — در حال اتصال...'); } } catch (e) { debugPrint('❌ mic start error: $e'); if (mounted) setState(() => _statusText = 'خطا در میکروفون'); } finally { _isStartingRecorder = false; } } void _startMicHealthWatchdog() { _micHealthTimer ??= Timer.periodic(const Duration(seconds: 2), (_) { if (_isStopping || !_isSessionReady) return; final now = DateTime.now(); final lastReceived = _lastMicChunkAt; if (lastReceived == null || now.difference(lastReceived) > const Duration(seconds: 3)) { debugPrint( '⚠️ mic watchdog: no audio chunks for 3s ' '(received=$_micChunksReceived sent=$_micChunksSent)', ); unawaited(_restartMicCapture('no chunks')); return; } final cooldown = _echoCooldownUntil; final cooldownActive = cooldown != null && now.isBefore(cooldown); if (!_assistantOwnsAudioTurn && !cooldownActive && (_lastMicChunkSentAt == null || now.difference(_lastMicChunkSentAt!) > const Duration(seconds: 3))) { // Expired timestamps are harmless, but clear them to keep state and // diagnostics truthful. Never clear an active physical echo tail. _echoCooldownUntil = null; } }); } Future _restartMicCapture(String reason) async { if (_isStopping || _isRestartingMic) return; _isRestartingMic = true; debugPrint('🔄 restarting microphone: $reason'); try { final oldSubscription = _audioStreamSubscription; _audioStreamSubscription = null; await oldSubscription?.cancel(); try { await _audioRecorder.stop(); } catch (_) {} _isRecording = false; _lastMicChunkAt = null; if (!_isStopping && mounted) { await _startMicCapture(); } } finally { _isRestartingMic = false; } } Future _stopAll() async { _isStopping = true; _bargeInProbeTimer?.cancel(); _bargeInProbeTimer = null; if (_bargeInProbeActive) { try { await _audioEventsChannel.invokeMethod('endOutputProbe'); } catch (_) {} _bargeInProbeActive = false; } _isConnecting = false; _connectionGeneration++; _reconnectTimer?.cancel(); _reconnectTimer = null; _micHealthTimer?.cancel(); _micHealthTimer = null; _playbackPumpTimer?.cancel(); _playbackCompletionTimer?.cancel(); _pendingPlaybackChunks.clear(); _playbackReferences.clear(); _estimatedPlaybackEnd = null; _serverAudioDone = false; _playbackSegmentSealed = false; _playbackSegmentStarted = false; _pendingPlaybackSampleCount = 0; _responseLifecycleDone = false; _resetBargeInDetector(); try { await _audioStreamSubscription?.cancel(); _audioStreamSubscription = null; } catch (_) {} try { if (await _audioRecorder.isRecording()) { await _audioRecorder.stop(); } } catch (_) {} // stop() alone leaves the native recorder (and Android's mic-in-use // indicator) alive — the OS keeps the capture session open until the // plugin instance is disposed. _stopAll only runs on terminal teardown // (close button / dispose), so releasing the recorder here is safe. try { await _audioRecorder.dispose(); } catch (_) {} // Bump generation + flip flag BEFORE stopping player — any in-flight // _playPcmChunk sees them and bails out before calling into native. _playerGeneration++; final wasInitialized = _isPlayerInitialized; _isPlayerInitialized = false; try { if (wasInitialized) { _audioPlayer.uninit(); } } catch (_) {} try { await _wsSub?.cancel(); } catch (_) {} try { await _ws?.sink.close(); } catch (_) {} _ws = null; _conversationId = null; _isRecording = false; _isStartingRecorder = false; _isSessionReady = false; _micBufferBeforeReady.clear(); _micBufferedBytes = 0; _isUserAudioLocked = false; _unlockUserMicTimer?.cancel(); } @override void dispose() { _audioEventsChannel.setMethodCallHandler(null); _orbController.dispose(); _rippleController.dispose(); _waveController.dispose(); _transcriptController.dispose(); _unlockUserMicTimer?.cancel(); _reconnectTimer?.cancel(); _micHealthTimer?.cancel(); _playbackPumpTimer?.cancel(); _playbackCompletionTimer?.cancel(); _turnWatchdog?.cancel(); _stopAll(); super.dispose(); } // ----- UI --------------------------------------------------------- static const Color _brandAccent = Color(0xFF8EA9FF); static const Color _dangerAccent = Color(0xFFFF7A86); _VoiceVisualState get _visualState { final status = _statusText; final hasError = status.contains('خطا') || status.contains('ناموفق') || status.contains('رد شد'); if (hasError) return _VoiceVisualState.error; if (!_isSessionReady || _isConnecting) { return _VoiceVisualState.connecting; } if (status.contains('جست‌وجو')) return _VoiceVisualState.searching; if (status.contains('آماده‌سازی')) return _VoiceVisualState.thinking; if (_isAiSpeaking) return _VoiceVisualState.speaking; if (_isMicMuted) return _VoiceVisualState.muted; if (_isUserSpeaking) return _VoiceVisualState.hearing; return _VoiceVisualState.listening; } String _titleForState(_VoiceVisualState state) { return switch (state) { _VoiceVisualState.connecting => 'در حال اتصال', _VoiceVisualState.listening => 'گوش می‌دهم', _VoiceVisualState.hearing => 'صدایتان را می‌شنوم', _VoiceVisualState.searching => 'در حال جست‌وجوی وب', _VoiceVisualState.thinking => 'در حال آماده‌سازی پاسخ', _VoiceVisualState.speaking => 'هوشان در حال صحبت است', _VoiceVisualState.muted => 'میکروفن خاموش است', _VoiceVisualState.error => 'مشکلی پیش آمده', }; } String _descriptionForState(_VoiceVisualState state) { return switch (state) { _VoiceVisualState.connecting => 'در حال آماده‌سازی گفت‌وگوی امن', _VoiceVisualState.listening => 'سؤال‌تان را طبیعی و واضح بپرسید', _VoiceVisualState.hearing => 'بعد از مکث کوتاه، پاسخ می‌دهم', _VoiceVisualState.searching => 'در حال بررسی تازه‌ترین منابع', _VoiceVisualState.thinking => 'نتایج در حال تبدیل‌شدن به پاسخ هستند', _VoiceVisualState.speaking => 'برای صحبت، پاسخ را قطع کنید', _VoiceVisualState.muted => 'برای ادامه، میکروفن را روشن کنید', _VoiceVisualState.error => _statusText, }; } IconData _iconForState(_VoiceVisualState state) { return switch (state) { _VoiceVisualState.connecting => Icons.sync_rounded, _VoiceVisualState.listening => Icons.hearing_rounded, _VoiceVisualState.hearing => Icons.graphic_eq_rounded, _VoiceVisualState.searching => Icons.travel_explore_rounded, _VoiceVisualState.thinking => Icons.auto_awesome_rounded, _VoiceVisualState.speaking => Icons.graphic_eq_rounded, _VoiceVisualState.muted => Icons.mic_off_rounded, _VoiceVisualState.error => Icons.error_outline_rounded, }; } Color _accentForState(_VoiceVisualState state) { return switch (state) { _VoiceVisualState.error => _dangerAccent, _VoiceVisualState.muted => const Color(0xFF9BA1B2), _ => _brandAccent, }; } @override Widget build(BuildContext context) { final visualState = _visualState; final accent = _accentForState(visualState); final reduceMotion = MediaQuery.disableAnimationsOf(context); final activity = _isAiSpeaking ? _aiAudioLevel : (_isUserSpeaking ? _userAudioLevel : 0.0); return Dialog.fullscreen( backgroundColor: const Color(0xFF06070B), child: Directionality( textDirection: TextDirection.rtl, child: Stack( children: [ const Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color(0xFF0A0C13), Color(0xFF06070B), Color(0xFF08090D), ], ), ), ), ), Positioned.fill( child: AnimatedBuilder( animation: _orbController, builder: (_, __) => CustomPaint( painter: _AtmospherePainter( accent: accent, time: _orbController.value, activity: activity, ), ), ), ), SafeArea( child: LayoutBuilder( builder: (context, constraints) { final isCompact = constraints.maxHeight < 720; final hasTranscript = _messages.isNotEmpty || _liveAiTranscript.isNotEmpty; final orbAreaHeight = isCompact ? (hasTranscript ? 176.0 : 210.0) : (hasTranscript ? (constraints.maxHeight * 0.27).clamp(190.0, 250.0) : (constraints.maxHeight * 0.32).clamp(220.0, 285.0)); final contentWidth = math.min(constraints.maxWidth, 680.0).toDouble(); final orbSize = math .min(contentWidth * 0.62, orbAreaHeight) .clamp(170.0, 260.0) .toDouble(); return Align( alignment: Alignment.topCenter, child: SizedBox( width: contentWidth, child: Column( children: [ _buildHeader(), AnimatedContainer( duration: const Duration(milliseconds: 420), curve: Curves.easeOutCubic, height: orbAreaHeight, child: _buildOrb( accent, orbSize, reduceMotion: reduceMotion, ), ), _buildVoiceStatus(visualState, accent), SizedBox(height: isCompact ? 8 : 14), Expanded(child: _buildTranscript(accent)), _buildBottomControls(accent), ], ), ), ); }, ), ), ], ), ), ); } Widget _buildHeader() { return Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Container( width: 38, height: 38, decoration: BoxDecoration( color: Colors.white.withOpacity(0.045), borderRadius: BorderRadius.circular(13), border: Border.all(color: Colors.white.withOpacity(0.07)), ), child: const Icon( Icons.auto_awesome_rounded, color: _brandAccent, size: 19, ), ), const SizedBox(width: 12), const Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'هوشان', style: TextStyle( color: Colors.white, fontSize: 17, fontWeight: FontWeight.w700, ), ), Text( 'دستیار صوتی دیدوان', style: TextStyle( color: Color(0xFF9298A8), fontSize: 12, height: 1.45, ), ), ], ), ], ), Semantics( button: true, label: 'بستن گفت‌وگو', child: IconButton( tooltip: 'بستن', style: IconButton.styleFrom( minimumSize: const Size(44, 44), backgroundColor: Colors.white.withOpacity(0.045), side: BorderSide(color: Colors.white.withOpacity(0.07)), ), icon: const Icon( Icons.close_rounded, color: Color(0xFFC3C7D2), ), onPressed: _closeVoiceChat, ), ), ], ), ); } Widget _buildTranscript(Color accent) { final itemCount = _messages.length + (_liveAiTranscript.isNotEmpty ? 1 : 0); final visibleMessages = <_VoiceMessage>[ ..._messages, if (_liveAiTranscript.isNotEmpty) _VoiceMessage(_liveAiTranscript), ]; return Container( margin: const EdgeInsets.fromLTRB(18, 0, 18, 6), clipBehavior: Clip.antiAlias, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.white.withOpacity(0.045), Colors.white.withOpacity(0.022), ], ), borderRadius: BorderRadius.circular(26), border: Border.all(color: Colors.white.withOpacity(0.065)), ), child: itemCount == 0 ? Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 36), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.format_quote_rounded, color: accent.withOpacity(0.55), size: 26, ), const SizedBox(height: 10), Text( _isSessionReady ? 'متن پاسخ هوشان اینجا نمایش داده می‌شود' : 'گفت‌وگو تا چند لحظه دیگر آماده است', textAlign: TextAlign.center, style: const TextStyle( color: Color(0xFF9A9FAD), fontSize: 13, height: 1.65, ), ), ], ), ), ) : ListView.separated( controller: _transcriptController, padding: const EdgeInsets.fromLTRB(20, 20, 20, 24), itemCount: visibleMessages.length, separatorBuilder: (_, __) => const SizedBox(height: 20), itemBuilder: (context, index) { final isCurrent = index == visibleMessages.length - 1; return AnimatedOpacity( duration: const Duration(milliseconds: 280), opacity: isCurrent ? 1 : 0.48, child: Text( visibleMessages[index].text, textDirection: TextDirection.rtl, style: TextStyle( color: isCurrent ? const Color(0xFFE8EAF0) : const Color(0xFFC3C6D0), fontSize: isCurrent ? 15 : 14, height: 1.68, fontWeight: isCurrent ? FontWeight.w500 : FontWeight.w400, ), ), ); }, ), ); } Widget _buildBottomControls(Color accent) { return Container( padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedSize( duration: const Duration(milliseconds: 260), curve: Curves.easeOutCubic, child: _isAiSpeaking ? Padding( padding: const EdgeInsets.only(bottom: 12), child: Material( color: Colors.transparent, child: InkWell( onTap: _handleUserTakeover, borderRadius: BorderRadius.circular(15), child: Ink( height: 46, padding: const EdgeInsets.symmetric(horizontal: 18), decoration: BoxDecoration( color: accent.withOpacity(0.11), borderRadius: BorderRadius.circular(15), border: Border.all( color: accent.withOpacity(0.26), ), ), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.mic_rounded, color: accent, size: 20, ), const SizedBox(width: 9), const Text( 'قطع پاسخ و شروع صحبت', style: TextStyle( color: Color(0xFFE8EBF5), fontSize: 13, fontWeight: FontWeight.w600, ), ), ], ), ), ), ), ) : const SizedBox.shrink(), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildRoundControl( icon: _isMicMuted ? Icons.mic_off_rounded : Icons.mic_none_rounded, label: _isMicMuted ? 'روشن‌کردن میکروفن' : 'میکروفن', color: _isMicMuted ? _dangerAccent : accent, filled: _isMicMuted, onTap: _toggleMicrophone, ), const SizedBox(width: 22), _buildRoundControl( icon: Icons.call_end_rounded, label: 'پایان', color: _dangerAccent, filled: true, onTap: _closeVoiceChat, ), ], ), ], ), ); } Widget _buildRoundControl({ required IconData icon, required String label, required Color color, required bool filled, required VoidCallback onTap, }) { return Semantics( button: true, label: label, child: Column( mainAxisSize: MainAxisSize.min, children: [ Material( color: Colors.transparent, child: InkWell( onTap: onTap, customBorder: const CircleBorder(), child: AnimatedContainer( duration: const Duration(milliseconds: 220), width: 58, height: 58, decoration: BoxDecoration( shape: BoxShape.circle, color: filled ? color.withOpacity(0.18) : Colors.white.withOpacity(0.045), border: Border.all( color: filled ? color.withOpacity(0.5) : Colors.white.withOpacity(0.085), ), ), child: Icon(icon, color: color, size: 24), ), ), ), const SizedBox(height: 8), Text( label, style: TextStyle( color: filled ? const Color(0xFFCBCFD9) : const Color(0xFFADB2C0), fontSize: 12, fontWeight: FontWeight.w500, ), ), ], ), ); } Widget _buildOrb( Color accent, double size, { required bool reduceMotion, }) { return AnimatedBuilder( animation: Listenable.merge([_orbController, _rippleController]), builder: (context, _) { final time = reduceMotion ? 0.0 : _rippleController.value; final voiceEnergy = _orbEnvelope; final transient = reduceMotion ? 0.0 : _orbTransient; final texture = reduceMotion ? 0.0 : _orbTexture; final motionPhase = reduceMotion ? 0.0 : _orbMotionPhase; final shaderActivity = (0.035 + voiceEnergy * 0.965).clamp(0.0, 1.0); final scaleX = reduceMotion ? 1.0 : 1 + voiceEnergy * 0.018 + transient * 0.024 + _orbVelocity * 0.018; final scaleY = reduceMotion ? 1.0 : 1 + voiceEnergy * 0.034 - transient * 0.011 - _orbVelocity * 0.025; final opticalDrift = reduceMotion ? Offset.zero : Offset( math.sin(motionPhase * 0.73) * voiceEnergy * 3.2 + math.sin(motionPhase * 1.91) * texture * 0.8, -voiceEnergy * 1.7 + math.cos(motionPhase * 0.83) * voiceEnergy * 1.8 + _orbVelocity * 24 + transient * math.sin(motionPhase * 1.37) * 3.6, ); final tilt = reduceMotion ? 0.0 : math.sin(motionPhase * 0.51) * voiceEnergy * 0.008 + _orbVelocity * 0.021; return GestureDetector( onTap: _isAiSpeaking ? _handleUserTakeover : null, child: Center( child: SizedBox( width: size, height: size, child: Stack( alignment: Alignment.center, children: [ Transform.translate( offset: opticalDrift, child: Transform.rotate( angle: tilt, child: Transform.scale( scaleX: scaleX, scaleY: scaleY, child: RepaintBoundary( child: SizedBox( width: size, height: size, child: CustomPaint( painter: _orbShader != null ? _OrbShaderPainter( shader: _orbShader!, accent: accent, time: time, activity: shaderActivity, transient: transient, texture: texture, motionPhase: motionPhase, ) : _LuminousOrbPainter( accent: accent, time: 0, activity: 0, ), ), ), ), ), ), ), ], ), ), ), ); }, ); } Widget _buildVoiceStatus(_VoiceVisualState state, Color accent) { final isReactive = state == _VoiceVisualState.speaking || state == _VoiceVisualState.hearing; return AnimatedSwitcher( duration: const Duration(milliseconds: 240), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, child: Column( key: ValueKey(state), mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ if (isReactive) SizedBox( width: 25, height: 18, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: List.generate(3, (index) { final waveIndex = ((index + 1) * _audioWaveHeights.length ~/ 4) .clamp(0, _audioWaveHeights.length - 1); final value = state == _VoiceVisualState.speaking ? _audioWaveHeights[waveIndex] : _userAudioLevel; return AnimatedContainer( duration: const Duration(milliseconds: 70), width: 3, height: (5 + value * 13).clamp(5.0, 18.0), decoration: BoxDecoration( color: accent, borderRadius: BorderRadius.circular(3), ), ); }), ), ) else Icon(_iconForState(state), color: accent, size: 19), const SizedBox(width: 9), Text( _titleForState(state), style: const TextStyle( color: Color(0xFFF0F1F5), fontSize: 15, fontWeight: FontWeight.w600, ), ), ], ), const SizedBox(height: 5), Padding( padding: const EdgeInsets.symmetric(horizontal: 28), child: Text( _descriptionForState(state), maxLines: state == _VoiceVisualState.error ? 2 : 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: const TextStyle( color: Color(0xFF989EAD), fontSize: 12, height: 1.5, ), ), ), ], ), ); } } class _OrbShaderPainter extends CustomPainter { const _OrbShaderPainter({ required this.shader, required this.accent, required this.time, required this.activity, required this.transient, required this.texture, required this.motionPhase, }); final ui.FragmentShader shader; final Color accent; final double time; final double activity; final double transient; final double texture; final double motionPhase; @override void paint(Canvas canvas, Size size) { shader ..setFloat(0, size.width) ..setFloat(1, size.height) ..setFloat(2, time) ..setFloat(3, activity.clamp(0, 1)) ..setFloat(4, accent.r) ..setFloat(5, accent.g) ..setFloat(6, accent.b) ..setFloat(7, transient.clamp(0, 1)) ..setFloat(8, texture.clamp(0, 1)) ..setFloat(9, motionPhase); canvas.drawRect(Offset.zero & size, Paint()..shader = shader); } @override bool shouldRepaint(covariant _OrbShaderPainter old) => old.time != time || old.accent != accent || old.activity != activity || old.transient != transient || old.texture != texture || old.motionPhase != motionPhase; } class _LuminousOrbPainter extends CustomPainter { final Color accent; final double time; final double activity; _LuminousOrbPainter({ required this.accent, required this.time, required this.activity, }); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final r = math.min(size.width, size.height) * 0.285; final phase = time * math.pi * 2; for (var i = 3; i >= 0; i--) { final haloRadius = r * (1.15 + i * 0.25 + activity * 0.2); canvas.drawCircle( center, haloRadius, Paint() ..color = accent.withOpacity(0.025 + activity * 0.018) ..maskFilter = MaskFilter.blur(BlurStyle.normal, 22 + i * 10), ); } final shadowRect = Rect.fromCenter( center: Offset(center.dx, center.dy + r * 1.15), width: r * 1.7, height: r * 0.22, ); canvas.drawOval( shadowRect, Paint() ..shader = RadialGradient( colors: [accent.withOpacity(0.22), Colors.transparent], ).createShader(shadowRect) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 14), ); final orb = Path(); const points = 120; for (var i = 0; i <= points; i++) { final a = i / points * math.pi * 2; final deformation = activity * (0.025 * math.sin(a * 3 + phase * 2.2) + 0.014 * math.sin(a * 5 - phase * 3.1) + 0.008 * math.sin(a * 9 + phase)); final radius = r * (1 + deformation); final point = center + Offset(math.cos(a) * radius, math.sin(a) * radius); if (i == 0) { orb.moveTo(point.dx, point.dy); } else { orb.lineTo(point.dx, point.dy); } } orb.close(); final bodyRect = Rect.fromCircle(center: center, radius: r * 1.08); canvas.drawPath( orb, Paint() ..shader = RadialGradient( center: const Alignment(-0.42, -0.48), radius: 1.18, colors: [ const Color(0xFFE9F4FF).withOpacity(0.96), Color.lerp(accent, const Color(0xFF6B78B8), 0.38)!, Color.lerp(accent, const Color(0xFF111528), 0.72)!, const Color(0xFF03050A), ], stops: const [0, 0.22, 0.63, 1], ).createShader(bodyRect), ); canvas.save(); canvas.clipPath(orb); final innerRect = Rect.fromCircle(center: center, radius: r * 0.96); canvas.drawCircle( center, r * 0.94, Paint() ..shader = SweepGradient( transform: GradientRotation(phase * 0.45), colors: [ Colors.transparent, accent.withOpacity(0.12 + activity * 0.18), const Color(0xFFB9E7FF).withOpacity(0.24), Colors.transparent, const Color(0xFFD9C6FF).withOpacity(0.14), Colors.transparent, ], stops: const [0, 0.2, 0.36, 0.55, 0.76, 1], ).createShader(innerRect) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12), ); for (var i = 0; i < 4; i++) { final y = center.dy - r * 0.55 + i * r * 0.34; final rect = Rect.fromCenter( center: Offset(center.dx + math.sin(phase + i) * r * 0.08, y), width: r * (1.65 - i * 0.08), height: r * 0.38, ); canvas.drawArc( rect, math.pi * (0.08 + i * 0.1) + phase * 0.12, math.pi * (0.75 + activity * 0.22), false, Paint() ..color = Colors.white.withOpacity(0.055 + activity * 0.045) ..style = PaintingStyle.stroke ..strokeWidth = 1.2 + activity * 1.8 ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2), ); } final glassReflection = Rect.fromCenter( center: Offset(center.dx - r * 0.34, center.dy - r * 0.38), width: r * 0.62, height: r * 1.05, ); canvas.drawOval( glassReflection, Paint() ..shader = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Colors.white.withOpacity(0.52), Colors.white.withOpacity(0.07), Colors.transparent, ], stops: const [0, 0.34, 1], ).createShader(glassReflection) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6), ); canvas.restore(); canvas.drawPath( orb, Paint() ..shader = SweepGradient( transform: GradientRotation(phase * 0.12), colors: [ Colors.white.withOpacity(0.74), accent.withOpacity(0.12), Colors.transparent, accent.withOpacity(0.7), Colors.white.withOpacity(0.74), ], ).createShader(bodyRect) ..style = PaintingStyle.stroke ..strokeWidth = 1.15 ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.4), ); final hotSpot = Offset(center.dx - r * 0.42, center.dy - r * 0.52); canvas.drawCircle( hotSpot, r * 0.19, Paint() ..shader = RadialGradient( colors: [ Colors.white.withOpacity(0.92), Colors.white.withOpacity(0.18), Colors.transparent, ], stops: const [0, 0.26, 1], ).createShader(Rect.fromCircle(center: hotSpot, radius: r * 0.19)), ); } @override bool shouldRepaint(covariant _LuminousOrbPainter old) => old.time != time || old.accent != accent || old.activity != activity; } class _AtmospherePainter extends CustomPainter { const _AtmospherePainter({ required this.accent, required this.time, required this.activity, }); final Color accent; final double time; final double activity; @override void paint(Canvas canvas, Size size) { final orbCenter = Offset(size.width / 2, size.height * 0.28); final glowRect = Rect.fromCircle( center: orbCenter, radius: size.width * (0.58 + activity * 0.08), ); canvas.drawCircle( orbCenter, glowRect.width / 2, Paint() ..shader = RadialGradient( colors: [ accent.withOpacity(0.07 + activity * 0.035), accent.withOpacity(0.016), Colors.transparent, ], stops: const [0, 0.42, 1], ).createShader(glowRect), ); final leftLight = Rect.fromCenter( center: Offset(-size.width * 0.02, size.height * 0.26), width: size.width * 0.42, height: size.height * 0.8, ); canvas.drawOval( leftLight, Paint() ..color = const Color(0xFF6688C8).withOpacity(0.012) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 55), ); final rightLight = Rect.fromCenter( center: Offset(size.width * 1.03, size.height * 0.47), width: size.width * 0.46, height: size.height * 0.74, ); canvas.drawOval( rightLight, Paint() ..color = accent.withOpacity(0.01 + activity * 0.006) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 60), ); } @override bool shouldRepaint(covariant _AtmospherePainter old) => old.accent != accent || old.activity != activity; }