3004 lines
111 KiB
Dart
3004 lines
111 KiB
Dart
// 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.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<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 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<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;
|
||
}
|