import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatCardModule } from '@angular/material/card'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatDividerModule } from '@angular/material/divider'; import { MatChipsModule } from '@angular/material/chips'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { Inject } from '@angular/core'; import { Subject, Subscription, takeUntil } from 'rxjs'; import { ConversationManagerService, ConversationState, ConversationMessage } from '../../services/conversation-manager.service'; import { AudioStreamService } from '../../services/audio-stream.service'; @Component({ selector: 'app-realtime-chat', standalone: true, imports: [ CommonModule, MatCardModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule, MatDividerModule, MatChipsModule, MatSnackBarModule ], templateUrl: './realtime-chat.component.html', styleUrls: ['./realtime-chat.component.scss'] }) export class RealtimeChatComponent implements OnInit, OnDestroy, AfterViewChecked { @ViewChild('scrollContainer') private scrollContainer!: ElementRef; @ViewChild('audioVisualizer') private audioVisualizer!: ElementRef; sessionId: string | null = null; projectName: string | null = null; isConversationActive = false; isRecording = false; isPlayingAudio = false; currentState: ConversationState = 'idle'; messages: ConversationMessage[] = []; error = ''; loading = false; conversationStates: ConversationState[] = [ 'idle', 'listening', 'processing_stt', 'processing_llm', 'processing_tts', 'playing_audio' ]; private destroyed$ = new Subject(); private subscriptions = new Subscription(); private shouldScrollToBottom = false; private animationId: number | null = null; private currentAudio: HTMLAudioElement | null = null; private volumeUpdateSubscription?: Subscription; constructor( private conversationManager: ConversationManagerService, private audioService: AudioStreamService, private snackBar: MatSnackBar, public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: { sessionId: string; projectName?: string } ) { this.sessionId = data.sessionId; this.projectName = data.projectName || null; } ngOnInit(): void { console.log('🎤 RealtimeChat component initialized'); console.log('Session ID:', this.sessionId); console.log('Project Name:', this.projectName); // Subscribe to messages FIRST - before any connection this.conversationManager.messages$.pipe( takeUntil(this.destroyed$) ).subscribe(messages => { console.log('💬 Messages updated:', messages.length, 'messages'); this.messages = messages; this.shouldScrollToBottom = true; // Check if we have initial welcome message if (messages.length > 0) { const lastMessage = messages[messages.length - 1]; console.log('📝 Last message:', lastMessage.role, lastMessage.text?.substring(0, 50) + '...'); } }); // Check browser support if (!AudioStreamService.checkBrowserSupport()) { this.error = 'Tarayıcınız ses kaydını desteklemiyor. Lütfen modern bir tarayıcı kullanın.'; this.snackBar.open(this.error, 'Close', { duration: 5000, panelClass: 'error-snackbar' }); return; } // Check microphone permission this.checkMicrophonePermission(); // Subscribe to conversation state this.conversationManager.currentState$.pipe( takeUntil(this.destroyed$) ).subscribe(state => { console.log('📊 Conversation state:', state); this.currentState = state; // Recording state'i conversation active olduğu sürece true tut // Sadece error state'inde false yap this.isRecording = this.isConversationActive && state !== 'error'; }); // Subscribe to errors this.conversationManager.error$.pipe( takeUntil(this.destroyed$) ).subscribe(error => { console.error('Conversation error:', error); this.error = error.message; }); // Load initial messages from session if available const initialMessages = this.conversationManager.getMessages(); console.log('📋 Initial messages:', initialMessages.length); if (initialMessages.length > 0) { this.messages = initialMessages; this.shouldScrollToBottom = true; } } ngAfterViewChecked(): void { if (this.shouldScrollToBottom) { this.scrollToBottom(); this.shouldScrollToBottom = false; } } ngOnDestroy(): void { this.destroyed$.next(); this.destroyed$.complete(); this.subscriptions.unsubscribe(); this.stopVisualization(); this.cleanupAudio(); if (this.isConversationActive) { this.conversationManager.stopConversation(); } } async toggleConversation(): Promise { if (!this.sessionId) return; if (this.isConversationActive) { this.stopConversation(); } else { await this.startConversation(); } } async retryConnection(): Promise { this.error = ''; if (!this.isConversationActive && this.sessionId) { await this.startConversation(); } } clearChat(): void { this.conversationManager.clearMessages(); this.error = ''; } performBargeIn(): void { // Barge-in özelliği devre dışı this.snackBar.open('Barge-in özelliği şu anda devre dışı', 'Tamam', { duration: 2000 }); } playAudio(audioUrl?: string): void { if (!audioUrl) return; // Stop current audio if playing if (this.currentAudio) { this.currentAudio.pause(); this.currentAudio = null; this.isPlayingAudio = false; return; } this.currentAudio = new Audio(audioUrl); this.isPlayingAudio = true; this.currentAudio.play().catch(error => { console.error('Audio playback error:', error); this.isPlayingAudio = false; this.currentAudio = null; }); this.currentAudio.onended = () => { this.isPlayingAudio = false; this.currentAudio = null; }; this.currentAudio.onerror = () => { this.isPlayingAudio = false; this.currentAudio = null; this.snackBar.open('Ses çalınamadı', 'Close', { duration: 2000, panelClass: 'error-snackbar' }); }; } getStateLabel(state: ConversationState): string { const labels: Record = { 'idle': 'Bekliyor', 'listening': 'Dinliyor', 'processing_stt': 'Metin Dönüştürme', 'processing_llm': 'Yanıt Hazırlanıyor', 'processing_tts': 'Ses Oluşturuluyor', 'playing_audio': 'Konuşuyor', 'error': 'Hata' }; return labels[state] || state; } closeDialog(): void { const result = this.isConversationActive ? 'session_active' : 'closed'; this.dialogRef.close(result); } trackByIndex(index: number): number { return index; } private async checkMicrophonePermission(): Promise { try { const permission = await this.audioService.checkMicrophonePermission(); if (permission === 'denied') { this.error = 'Mikrofon erişimi reddedildi. Lütfen tarayıcı ayarlarından izin verin.'; this.snackBar.open(this.error, 'Close', { duration: 5000, panelClass: 'error-snackbar' }); } } catch (error) { console.error('Failed to check microphone permission:', error); } } private scrollToBottom(): void { try { if (this.scrollContainer?.nativeElement) { const element = this.scrollContainer.nativeElement; element.scrollTop = element.scrollHeight; } } catch(err) { console.error('Scroll error:', err); } } async startConversation(): Promise { try { this.loading = true; this.error = ''; // Clear existing messages - welcome will come via WebSocket this.conversationManager.clearMessages(); await this.conversationManager.startConversation(this.sessionId!); this.isConversationActive = true; this.isRecording = true; // Konuşma başladığında recording'i aktif et // Visualization'ı başlat this.startVisualization(); this.snackBar.open('Konuşma başlatıldı', 'Close', { duration: 2000 }); } catch (error: any) { console.error('Failed to start conversation:', error); this.error = 'Konuşma başlatılamadı. Lütfen tekrar deneyin.'; this.snackBar.open(this.error, 'Close', { duration: 5000, panelClass: 'error-snackbar' }); } finally { this.loading = false; } } private stopConversation(): void { this.conversationManager.stopConversation(); this.isConversationActive = false; this.isRecording = false; // Konuşma bittiğinde recording'i kapat this.stopVisualization(); this.snackBar.open('Konuşma sonlandırıldı', 'Close', { duration: 2000 }); } private startVisualization(): void { // Eğer zaten çalışıyorsa tekrar başlatma if (!this.audioVisualizer || this.animationId) { return; } const canvas = this.audioVisualizer.nativeElement; const ctx = canvas.getContext('2d'); if (!ctx) { console.warn('Could not get canvas context'); return; } // Set canvas size canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; // Create gradient for bars const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); gradient.addColorStop(0, '#4caf50'); gradient.addColorStop(0.5, '#66bb6a'); gradient.addColorStop(1, '#4caf50'); let lastVolume = 0; let targetVolume = 0; const smoothingFactor = 0.8; // Subscribe to volume updates this.volumeUpdateSubscription = this.audioService.volumeLevel$.subscribe(volume => { targetVolume = volume; }); // Animation loop const animate = () => { // isConversationActive kontrolü ile devam et if (!this.isConversationActive) { this.clearVisualization(); return; } // Clear canvas ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Smooth volume transition lastVolume = lastVolume * smoothingFactor + targetVolume * (1 - smoothingFactor); // Draw frequency bars const barCount = 32; const barWidth = canvas.width / barCount; const barSpacing = 2; for (let i = 0; i < barCount; i++) { // Create natural wave effect based on volume const frequencyFactor = Math.sin((i / barCount) * Math.PI); const timeFactor = Math.sin(Date.now() * 0.001 + i * 0.2) * 0.2 + 0.8; const randomFactor = 0.8 + Math.random() * 0.2; const barHeight = lastVolume * canvas.height * 0.7 * frequencyFactor * timeFactor * randomFactor; const x = i * barWidth; const y = (canvas.height - barHeight) / 2; // Draw bar ctx.fillStyle = gradient; ctx.fillRect(x + barSpacing / 2, y, barWidth - barSpacing, barHeight); // Draw reflection ctx.fillStyle = 'rgba(76, 175, 80, 0.2)'; ctx.fillRect(x + barSpacing / 2, canvas.height - y, barWidth - barSpacing, -barHeight * 0.3); } // Draw center line ctx.strokeStyle = 'rgba(76, 175, 80, 0.5)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, canvas.height / 2); ctx.lineTo(canvas.width, canvas.height / 2); ctx.stroke(); this.animationId = requestAnimationFrame(animate); }; animate(); } private stopVisualization(): void { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } if (this.volumeUpdateSubscription) { this.volumeUpdateSubscription.unsubscribe(); this.volumeUpdateSubscription = undefined; } this.clearVisualization(); } private clearVisualization(): void { if (!this.audioVisualizer) return; const canvas = this.audioVisualizer.nativeElement; const ctx = canvas.getContext('2d'); if (ctx) { ctx.fillStyle = '#212121'; ctx.fillRect(0, 0, canvas.width, canvas.height); } } private cleanupAudio(): void { if (this.currentAudio) { this.currentAudio.pause(); this.currentAudio = null; this.isPlayingAudio = false; } } }