didvan-app/lib/views/widgets/ai_voice_chat_dialog.dart

3004 lines
111 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.<token>` (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<AiVoiceChatDialog> createState() => _AiVoiceChatDialogState();
}
class _AiVoiceChatDialogState extends State<AiVoiceChatDialog>
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.020.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.0060.011 RMS, open-mic speech at ~0.066. AEC ducks the user's voice
// during playback to roughly 0.0150.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.030.094 and speaker distortion destroys waveform
// correlation (0.100.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<Uint8List>? _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<Uint8List> _micBufferBeforeReady = [];
int _micBufferedBytes = 0;
static const int _maxPreSessionAudioBytes = _sampleRate * 2 * 10;
// ----- animation --------------------------------------------------
late AnimationController _orbController;
late AnimationController _rippleController;
late AnimationController _waveController;
final List<double> _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<void> _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<void> _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<void> _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<void> _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<String?> _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<void> _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<String, dynamic> 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<String, dynamic>;
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<String, dynamic>.from(nestedError)
: const <String, dynamic>{};
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<void> _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<void> _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<void>('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<void> _finishBargeInProbe() async {
if (!_bargeInProbeActive) return;
_bargeInProbeTimer?.cancel();
_bargeInProbeTimer = null;
final confirmed =
_bargeInProbeVoiceChunks >= _bargeInProbeRequiredVoiceChunks;
final voiceChunks = _bargeInProbeVoiceChunks;
final peakRms = _bargeInProbePeakRms;
try {
await _audioEventsChannel.invokeMethod<void>('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 0700 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<void> _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<void> _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<void> _stopAll() async {
_isStopping = true;
_bargeInProbeTimer?.cancel();
_bargeInProbeTimer = null;
if (_bargeInProbeActive) {
try {
await _audioEventsChannel.invokeMethod<void>('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;
}