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

627 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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

// ignore_for_file: deprecated_member_use, avoid_print
//
// xAI Grok Voice Agent — Realtime API integration.
//
// Flow:
// 1) دریافت توکن کوتاه‌مدت از backend (POST /ai/voice/token)
// 2) اتصال WebSocket به wss://api.x.ai/v1/realtime?model=grok-voice-latest
// با subprotocol `xai-client-secret.<token>` (API key اصلی هیچ‌گاه به
// client فرستاده نمی‌شود).
// 3) ارسال session.update با voice=leo + instructions فارسی + server VAD
// 4) Mic capture با PCM16 mono 24kHz → base64 → input_audio_buffer.append
// 5) Audio playback با flutter_sound feedFromStream در 24kHz PCM
// 6) Server VAD: input_audio_buffer.speech_started → 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),
),
);
}),
),
);
}
}