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

926 lines
27 KiB
Dart

// ignore_for_file: deprecated_member_use
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'dart:ui';
import 'dart:math' as math;
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:record/record.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:socket_io_client/socket_io_client.dart' as IO;
import 'package:didvan/views/widgets/didvan/text.dart';
import 'package:path_provider/path_provider.dart';
import 'package:didvan/services/network/request.dart';
import 'package:audio_session/audio_session.dart';
class AiVoiceChatDialog extends StatefulWidget {
const AiVoiceChatDialog({super.key});
@override
State<AiVoiceChatDialog> createState() => _AiVoiceChatDialogState();
}
class _AiVoiceChatDialogState extends State<AiVoiceChatDialog>
with TickerProviderStateMixin {
final String _socketUrl = 'https://socket.houshan.ai';
IO.Socket? _socket;
bool _isConnected = false;
static const int geminiSampleRate = 24000;
static const double vadThreshold = 0.05;
static const double userInterruptThreshold = 0.25;
static const int ignoreInitialMs = 800;
static const int sustainedChunksRequired = 4;
int _interruptChunkCount = 0;
DateTime? _aiPlaybackStartTime;
static const int silenceTimeoutMs = 1000;
Timer? _silenceTimer;
bool _isSpeechActive = false;
bool _ignoreAudioDuringAIPlayback = false;
bool _isRecording = false;
bool _isAiSpeaking = false;
String _statusText = 'در حال اتصال...';
late AnimationController _orbController;
late AnimationController _rippleController;
late AnimationController _waveController;
final AudioRecorder _audioRecorder = AudioRecorder();
final FlutterSoundPlayer _audioPlayer = FlutterSoundPlayer();
StreamSubscription<Uint8List>? _audioStreamSubscription;
Timer? _vadTimer;
Timer? _speakingTimer;
bool _isPlayerInitialized = false;
final BytesBuilder _fullResponseAccumulator = BytesBuilder();
Timer? _playbackTriggerTimer;
bool _isPlayingFromQueue = false;
final List<int> _sendBuffer = [];
static const int targetChunkSize = 5462;
bool _wasStoppedByUser = false;
final List<double> _audioWaveHeights = List.generate(40, (_) => 0.1);
@override
void initState() {
super.initState();
_initAnimations();
_initAudio();
_connectSocket();
}
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) {
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 (_isSpeechActive) 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;
}
}
});
}
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,
usage: AndroidAudioUsage.voiceCommunication,
),
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
));
await _audioPlayer.openPlayer();
await _audioPlayer
.setSubscriptionDuration(const Duration(milliseconds: 10));
_isPlayerInitialized = true;
debugPrint('✅ Audio player initialized with VOICE CHAT + SPEAKER');
} catch (e) {
debugPrint('❌ Error initializing audio player: $e');
}
}
Future<void> _stopAiPlayback() async {
if (_isAiSpeaking ||
_isPlayingFromQueue ||
_fullResponseAccumulator.isNotEmpty) {
debugPrint('🛑 Stopping AI Playback (Interrupted)');
_wasStoppedByUser = true;
_playbackTriggerTimer?.cancel();
_fullResponseAccumulator.clear();
try {
if (_audioPlayer.isPlaying) {
await _audioPlayer.stopPlayer();
}
} catch (e) {
debugPrint("⚠️ Error stopping player: $e");
}
if (mounted) {
setState(() {
_isPlayingFromQueue = false;
_isAiSpeaking = false;
if (_isRecording) {
_statusText = '🎤 در حال صحبت...';
}
});
}
}
}
void _connectSocket() {
try {
_socket = IO.io(
_socketUrl,
IO.OptionBuilder()
.setTransports(['websocket'])
.setAuth({'token': 'Bearer ${RequestService.token}'})
.disableAutoConnect()
.build(),
);
_socket!.connect();
_socket!.onConnect((_) {
debugPrint('✅ Socket Connected');
_socket!.emit('test', 'Hello from Flutter');
if (mounted) {
setState(() {
_isConnected = true;
_statusText = 'آماده گوش دادن...';
});
_startRecordingStream();
}
});
_socket!.onDisconnect((_) {
debugPrint('❌ Socket Disconnected');
if (mounted) {
setState(() {
_isConnected = false;
_statusText = 'اتصال قطع شد';
});
}
});
_socket!
.on('gemini_audio_chunk', (data) => _handleGeminiAudioChunk(data));
_socket!.on('audio', (data) => _handleGeminiAudioChunk(data));
_socket!.on('audio_chunk', (data) => _handleGeminiAudioChunk(data));
_socket!.on('gemini_audio', (data) => _handleGeminiAudioChunk(data));
_socket!.on('response', (data) => _handleGeminiAudioChunk(data));
_socket!.on('error', (error) => debugPrint('❌ Server Error: $error'));
} catch (e) {
debugPrint('❌ Connection Error: $e');
setState(() => _statusText = 'خطا در اتصال');
}
}
Future<void> _startRecordingStream() async {
if (!_isConnected) return;
try {
if (await _audioRecorder.hasPermission()) {
await _stopAiPlayback();
setState(() {
_isRecording = true;
_statusText = '👂 در حال گوش دادن...';
_isAiSpeaking = false;
});
_sendBuffer.clear();
_waveController.repeat();
final stream = await _audioRecorder.startStream(
const RecordConfig(
encoder: AudioEncoder.pcm16bits,
sampleRate: 16000,
numChannels: 1,
echoCancel: true,
noiseSuppress: true,
autoGain: false,
),
);
_audioStreamSubscription = stream.listen((data) {
_processAudioChunk(data);
});
debugPrint('🎙️ Recording started at 16000Hz');
}
} catch (e) {
debugPrint('Start Recording Error: $e');
setState(() {
_isRecording = false;
_statusText = 'خطا در دسترسی به میکروفون';
});
}
}
void _flushAiBuffers({bool resetStatus = true}) {
_playbackTriggerTimer?.cancel();
_fullResponseAccumulator.clear();
_isPlayingFromQueue = false;
_isAiSpeaking = false;
_ignoreAudioDuringAIPlayback = false;
_aiPlaybackStartTime = null;
_interruptChunkCount = 0;
try {
if (_audioPlayer.isPlaying) {
_audioPlayer.stopPlayer().catchError((e) {
debugPrint('⚠️ Error stopping: $e');
});
}
} catch (_) {}
if (resetStatus && mounted) {
setState(() {
_statusText = _isRecording ? '🎤 در حال صحبت...' : 'آماده';
});
}
}
void _processAudioChunk(Uint8List chunkData) {
if (!_isRecording || _socket == null) return;
double sumSquares = 0;
int sampleCount = 0;
for (int i = 0; i < chunkData.length; i += 2) {
if (i + 1 < chunkData.length) {
int sample = chunkData[i] | (chunkData[i + 1] << 8);
if (sample > 32767) sample -= 65536;
double floatSample = sample / 32768.0;
sumSquares += floatSample * floatSample;
sampleCount++;
}
}
final rms = sampleCount > 0 ? math.sqrt(sumSquares / sampleCount) : 0;
if (_ignoreAudioDuringAIPlayback) {
if (_aiPlaybackStartTime != null) {
final elapsed =
DateTime.now().difference(_aiPlaybackStartTime!).inMilliseconds;
if (elapsed < ignoreInitialMs) {
return;
}
if (rms > userInterruptThreshold) {
_interruptChunkCount++;
if (_interruptChunkCount >= sustainedChunksRequired) {
debugPrint(
'🧯 User speaking detected! RMS: ${rms.toStringAsFixed(3)}, Chunks: $_interruptChunkCount - Interrupting AI');
_wasStoppedByUser = true;
_interruptChunkCount = 0;
_flushAiBuffers();
} else {
return;
}
} else {
_interruptChunkCount = 0;
return;
}
}
}
double currentThreshold = vadThreshold;
if (rms > currentThreshold) {
if (_silenceTimer?.isActive ?? false) {
_silenceTimer!.cancel();
}
if (!_isSpeechActive) {
_isSpeechActive = true;
setState(() => _statusText = '🎤 در حال صحبت...');
}
} else {
if (_isSpeechActive &&
(_silenceTimer == null || !_silenceTimer!.isActive)) {
_silenceTimer =
Timer(const Duration(milliseconds: silenceTimeoutMs), () {
_isSpeechActive = false;
_silenceTimer = null;
setState(() => _statusText = 'درحال گوش دادن...');
});
}
}
if (_isSpeechActive || (_silenceTimer?.isActive ?? false)) {
_sendBuffer.addAll(chunkData);
while (_sendBuffer.length >= targetChunkSize) {
final chunkToSend = _sendBuffer.sublist(0, targetChunkSize);
final remaining = _sendBuffer.sublist(targetChunkSize);
_sendBuffer.clear();
_sendBuffer.addAll(remaining);
final base64Audio = base64Encode(Uint8List.fromList(chunkToSend));
_socket!.emit('audio_chunk', base64Audio);
}
} else {
if (_sendBuffer.isNotEmpty) {
final base64Audio = base64Encode(Uint8List.fromList(_sendBuffer));
_socket!.emit('audio_chunk', base64Audio);
_sendBuffer.clear();
}
}
}
Future<void> _stopRecordingStream() async {
_silenceTimer?.cancel();
_silenceTimer = null;
if (!_isRecording) return;
try {
setState(() {
_isRecording = false;
_statusText = 'متوقف شد';
});
_waveController.stop();
_vadTimer?.cancel();
await _audioStreamSubscription?.cancel();
_audioStreamSubscription = null;
await _audioRecorder.stop();
_sendBuffer.clear();
} catch (e) {
debugPrint('Stop Recording Error: $e');
}
}
Uint8List _reduceAudioVolume(Uint8List audioData, double volumeFactor) {
final result = Uint8List(audioData.length);
debugPrint(
'🔉 Reducing volume: ${audioData.length} bytes, factor: $volumeFactor');
int maxOriginal = 0;
int maxReduced = 0;
for (int i = 0; i < audioData.length - 1; i += 2) {
int low = audioData[i];
int high = audioData[i + 1];
int sample = (high << 8) | low;
if (sample >= 32768) {
sample -= 65536;
}
if (sample.abs() > maxOriginal) maxOriginal = sample.abs();
double reduced = sample * volumeFactor;
int newSample = reduced.round();
newSample = newSample.clamp(-32768, 32767);
if (newSample.abs() > maxReduced) maxReduced = newSample.abs();
if (newSample < 0) {
newSample += 65536;
}
result[i] = newSample & 0xFF;
result[i + 1] = (newSample >> 8) & 0xFF;
}
debugPrint(
'✅ Volume reduced - Max original: $maxOriginal, Max reduced: $maxReduced');
return result;
}
void _handleGeminiAudioChunk(dynamic data) {
try {
_ignoreAudioDuringAIPlayback = true;
_aiPlaybackStartTime = DateTime.now();
String base64String;
if (data is String) {
base64String = data;
} else if (data is Map && data['data'] != null) {
base64String = data['data'] as String;
} else {
return;
}
if (base64String.startsWith('data:')) {
base64String = base64String.split(',').last;
}
final Uint8List bytes = base64Decode(base64String);
if (bytes.isEmpty) return;
_fullResponseAccumulator.add(bytes);
if (mounted && !_isAiSpeaking) {
setState(() {
_isAiSpeaking = true;
_statusText = '🔄 منتظر پاسخ AI...';
});
}
_playbackTriggerTimer?.cancel();
_playbackTriggerTimer = Timer(const Duration(milliseconds: 800), () {
debugPrint(
'✅ Response complete. Total size: ${_fullResponseAccumulator.length} bytes');
_playAccumulatedAudio();
});
} catch (e) {
debugPrint('❌ Error receiving chunk: $e');
}
}
Future<void> _playAccumulatedAudio() async {
if (_isSpeechActive) {
debugPrint('⚠️ User is speaking, cancelling AI playback');
_fullResponseAccumulator.clear();
_isAiSpeaking = false;
return;
}
if (_isPlayingFromQueue) {
debugPrint('⚠️ Already playing, skipping...');
return;
}
final Uint8List totalAudioData = _fullResponseAccumulator.toBytes();
_fullResponseAccumulator.clear();
if (totalAudioData.isEmpty) {
debugPrint('⚠️ No audio data to play');
return;
}
debugPrint('📊 Original audio size: ${totalAudioData.length} bytes');
final reducedAudioData = _reduceAudioVolume(totalAudioData, 0.08);
debugPrint('📊 Reduced audio size: ${reducedAudioData.length} bytes');
_isPlayingFromQueue = true;
try {
if (mounted) {
setState(() => _statusText = '🔊 AI در حال صحبت...');
}
if (kIsWeb) {
await _audioPlayer.startPlayer(
fromDataBuffer: reducedAudioData,
codec: fsp.Codec.pcm16,
sampleRate: geminiSampleRate,
numChannels: 1,
whenFinished: () {
_onPlaybackFinished();
},
);
} else {
final tempDir = await getTemporaryDirectory();
final tempFile = File(
'${tempDir.path}/ai_response_${DateTime.now().millisecondsSinceEpoch}.pcm');
await tempFile.writeAsBytes(reducedAudioData);
await _audioPlayer.startPlayer(
fromURI: tempFile.path,
codec: fsp.Codec.pcm16,
sampleRate: geminiSampleRate,
numChannels: 1,
whenFinished: () {
_onPlaybackFinished(tempFile: tempFile);
},
);
}
} catch (e) {
debugPrint('❌ Playback Error: $e');
_isPlayingFromQueue = false;
if (mounted) {
setState(() {
_isAiSpeaking = false;
_statusText = _isRecording ? '👂 در حال گوش دادن...' : 'آماده';
});
}
}
}
void _onPlaybackFinished({File? tempFile}) {
debugPrint('✅ Playback finished');
if (!_wasStoppedByUser) {
_ignoreAudioDuringAIPlayback = false;
_aiPlaybackStartTime = null;
}
if (tempFile != null) {
try {
if (tempFile.existsSync()) tempFile.deleteSync();
} catch (e) {
/* ignore */
}
}
if (!_wasStoppedByUser) {
if (mounted) {
setState(() {
_isAiSpeaking = false;
_isPlayingFromQueue = false;
_statusText = _isRecording ? '👂 در حال گوش دادن...' : 'آماده';
});
}
} else {
_wasStoppedByUser = false;
}
}
@override
void dispose() {
_stopAiPlayback();
_audioStreamSubscription?.cancel();
if (_isRecording) _audioRecorder.stop();
_vadTimer?.cancel();
_speakingTimer?.cancel();
_socket?.disconnect();
_socket?.dispose();
_orbController.dispose();
_rippleController.dispose();
_waveController.dispose();
_audioRecorder.dispose();
if (_isPlayerInitialized) {
_audioPlayer.closePlayer();
}
super.dispose();
}
List<Color> _getGradientColors() {
if (!_isConnected) {
return [
const Color(0xFF2C3E50),
const Color(0xFF000000),
];
}
if (_isAiSpeaking) {
return [
const Color(0xFF1A2980),
const Color(0xFF26D0CE),
];
}
if (_isSpeechActive) {
return [
const Color(0xFFCB356B),
const Color(0xFFBD3F32),
];
}
return [
const Color(0xFF141E30),
const Color(0xFF243B55),
];
}
Color _getPrimaryGlowColor() {
if (!_isConnected) return Colors.red.shade400;
if (_isAiSpeaking) return const Color.fromARGB(255, 0, 170, 170);
if (_isSpeechActive) return const Color.fromARGB(255, 196, 4, 4);
return Colors.white.withOpacity(0.5);
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: true,
onPopInvoked: (didPop) async {
if (didPop) {
await _stopRecordingStream();
}
},
child: Dialog(
backgroundColor: Colors.transparent,
insetPadding: EdgeInsets.zero,
child: AnimatedContainer(
duration: const Duration(milliseconds: 1000),
curve: Curves.easeInOut,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: _getGradientColors(),
),
),
child: Stack(
children: [
Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(color: Colors.transparent),
),
),
SafeArea(
child: Column(
children: [
_buildTopBar(context),
const Spacer(),
_buildCentralVisualizer(),
const Spacer(),
_buildStatusArea(),
const SizedBox(height: 40),
],
),
),
],
),
),
),
);
}
Widget _buildTopBar(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: () async {
await _stopRecordingStream();
if (context.mounted) Navigator.pop(context);
},
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withOpacity(0.1),
),
child: const Icon(Icons.close, color: Colors.white, size: 20),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.auto_awesome, color: Colors.white70, size: 14),
const SizedBox(width: 8),
DidvanText(
'هوشان ، دستیار صوتی دیدوان',
color: Colors.white.withOpacity(0.9),
fontSize: 12,
fontWeight: FontWeight.w600,
),
],
),
),
const SizedBox(width: 48),
],
),
);
}
Widget _buildCentralVisualizer() {
return SizedBox(
height: 350,
width: 350,
child: Stack(
alignment: Alignment.center,
children: [
AnimatedBuilder(
animation: _rippleController,
builder: (context, child) {
return CustomPaint(
painter: RipplePainter(
animationValue: _rippleController.value,
color: _getPrimaryGlowColor(),
),
size: const Size(350, 350),
);
},
),
AnimatedBuilder(
animation: _waveController,
builder: (context, child) {
return CustomPaint(
painter: ModernWavePainter(
waveHeights: _audioWaveHeights,
color: _getPrimaryGlowColor(),
),
size: const Size(280, 280),
);
},
),
AnimatedBuilder(
animation: _orbController,
builder: (context, child) {
final scale = 1.0 +
(_orbController.value * 0.05) +
(math.max(0, _audioWaveHeights.reduce(math.max) * 0.1));
return Transform.scale(
scale: scale,
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: _getPrimaryGlowColor().withOpacity(0.6),
blurRadius: 50,
spreadRadius: 10,
),
BoxShadow(
color: Colors.white.withOpacity(0.3),
blurRadius: 20,
spreadRadius: 0,
),
],
gradient: RadialGradient(
colors: [
Colors.white.withOpacity(0.95),
_getPrimaryGlowColor().withOpacity(0.8),
_getPrimaryGlowColor().withOpacity(0.2),
],
stops: const [0.1, 0.6, 1.0],
),
),
child: Center(
child: Icon(
_isAiSpeaking ? Icons.graphic_eq : Icons.mic_none_rounded,
color: Colors.white.withOpacity(0.9),
size: 40,
),
),
),
);
},
),
],
),
);
}
Widget _buildStatusArea() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (Widget child, Animation<double> animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.2),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: DidvanText(
_statusText,
key: ValueKey<String>(_statusText),
fontSize: 18,
fontWeight: FontWeight.w500,
color: Colors.white.withOpacity(0.95),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 12),
],
);
}
}
class ModernWavePainter extends CustomPainter {
final List<double> waveHeights;
final Color color;
ModernWavePainter({required this.waveHeights, required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color.withOpacity(0.3)
..style = PaintingStyle.stroke
..strokeWidth = 2.0
..strokeCap = StrokeCap.round;
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2.5;
for (int j = 0; j < 2; j++) {
final path = Path();
final count = waveHeights.length;
final angleStep = (2 * math.pi) / count;
for (int i = 0; i <= count; i++) {
final index = i % count;
final angle = i * angleStep;
final offset = j * 5;
final waveVal = waveHeights[(index + offset) % count];
final r = radius + (waveVal * 30) + (j * 15);
final x = center.dx + r * math.cos(angle);
final y = center.dy + r * math.sin(angle);
if (i == 0) {
path.moveTo(x, y);
} else {
final prevAngle = (i - 1) * angleStep;
final prevIndex = (i - 1) % count;
final prevWave = waveHeights[(prevIndex + offset) % count];
final prevR = radius + (prevWave * 30) + (j * 15);
final prevX = center.dx + prevR * math.cos(prevAngle);
final prevY = center.dy + prevR * math.sin(prevAngle);
final midX = (prevX + x) / 2;
final midY = (prevY + y) / 2;
path.quadraticBezierTo(prevX, prevY, midX, midY);
}
}
path.close();
canvas.drawPath(path, paint);
}
}
@override
bool shouldRepaint(covariant ModernWavePainter oldDelegate) => true;
}
class RipplePainter extends CustomPainter {
final double animationValue;
final Color color;
RipplePainter({required this.animationValue, required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
final center = Offset(size.width / 2, size.height / 2);
final maxRadius = size.width / 2;
for (int i = 0; i < 3; i++) {
final progress = (animationValue + (i / 3)) % 1.0;
final radius = progress * maxRadius;
final opacity = (1.0 - progress).clamp(0.0, 1.0);
paint.color = color.withOpacity(opacity * 0.3);
canvas.drawCircle(center, radius, paint);
}
}
@override
bool shouldRepaint(covariant RipplePainter oldDelegate) => true;
}