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