Spaces:
Paused
Paused
| 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'; | |
| ({ | |
| 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 { | |
| ('scrollContainer') private scrollContainer!: ElementRef; | |
| ('audioVisualizer') private audioVisualizer!: ElementRef<HTMLCanvasElement>; | |
| 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<void>(); | |
| 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<RealtimeChatComponent>, | |
| (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<void> { | |
| if (!this.sessionId) return; | |
| if (this.isConversationActive) { | |
| this.stopConversation(); | |
| } else { | |
| await this.startConversation(); | |
| } | |
| } | |
| async retryConnection(): Promise<void> { | |
| 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<ConversationState, string> = { | |
| '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<void> { | |
| 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<void> { | |
| 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; | |
| } | |
| } | |
| } |