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, 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 ], template: ` voice_chat Real-time Conversation {{ getStateLabel(state) }} close error_outline {{ error }} refresh Dinleniyor... {{ currentTranscription }} {{ msg.role === 'user' ? 'person' : 'smart_toy' }} {{ msg.text }} {{ msg.timestamp | date:'HH:mm:ss' }} {{ isPlayingAudio ? 'stop' : 'volume_up' }} mic_off Konuşmaya başlamak için aşağıdaki butona tıklayın @if (loading) { } @else { {{ isConversationActive ? 'stop' : 'mic' }} {{ isConversationActive ? 'Konuşmayı Bitir' : 'Konuşmaya Başla' }} } clear Temizle pan_tool Kesme (Barge-in) `, styles: [` .realtime-chat-container { max-width: 800px; margin: 20px auto; height: 80vh; display: flex; flex-direction: column; position: relative; } mat-card-header { position: relative; .close-button { position: absolute; top: 8px; right: 8px; } } .error-banner { background-color: #ffebee; color: #c62828; padding: 12px; border-radius: 4px; display: flex; align-items: center; gap: 8px; margin-bottom: 16px; mat-icon { font-size: 20px; width: 20px; height: 20px; } span { flex: 1; } } .transcription-area { background: #f5f5f5; padding: 16px; border-radius: 8px; margin-bottom: 16px; min-height: 60px; animation: pulse 2s infinite; } @keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.7; } 100% { opacity: 1; } } .transcription-label { font-size: 12px; color: #666; margin-bottom: 4px; } .transcription-text { font-size: 16px; color: #333; min-height: 24px; } .chat-messages { flex: 1; overflow-y: auto; padding: 16px; background: #fafafa; border-radius: 8px; min-height: 200px; max-height: 400px; } .message { display: flex; align-items: flex-start; margin-bottom: 16px; animation: slideIn 0.3s ease-out; } @keyframes slideIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .message.user { flex-direction: row-reverse; } .message-icon { margin: 0 8px; color: #666; } .message-content { max-width: 70%; background: white; padding: 12px 16px; border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.1); position: relative; } .message.user .message-content { background: #3f51b5; color: white; } .message-text { margin-bottom: 4px; } .message-time { font-size: 11px; opacity: 0.7; } .audio-button { margin-top: 8px; } .empty-state { text-align: center; padding: 60px 20px; color: #999; mat-icon { font-size: 48px; width: 48px; height: 48px; margin-bottom: 16px; } } .audio-visualizer { width: 100%; height: 100px; background: #333; border-radius: 8px; margin-top: 16px; opacity: 0.3; transition: opacity 0.3s; } .audio-visualizer.active { opacity: 1; } mat-chip { font-size: 12px; } mat-chip.active { background-color: #3f51b5 !important; color: white !important; } mat-card-actions { padding: 16px; display: flex; gap: 16px; justify-content: flex-start; mat-spinner { display: inline-block; margin-right: 8px; } } `] }) 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'; currentTranscription = ''; messages: ConversationMessage[] = []; error = ''; loading = false; conversationStates: ConversationState[] = [ 'idle', 'listening', 'processing_stt', 'processing_llm', 'processing_tts', 'playing_audio' ]; private destroyed$ = new Subject(); private shouldScrollToBottom = false; private animationId: number | null = null; private currentAudio: HTMLAudioElement | null = null; 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 { // 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 => { this.currentState = state; this.updateRecordingState(state); }); // Subscribe to messages this.conversationManager.messages$.pipe( takeUntil(this.destroyed$) ).subscribe(messages => { this.messages = messages; this.shouldScrollToBottom = true; }); // Subscribe to transcription this.conversationManager.transcription$.pipe( takeUntil(this.destroyed$) ).subscribe(text => { this.currentTranscription = text; }); } ngAfterViewChecked(): void { if (this.shouldScrollToBottom) { this.scrollToBottom(); this.shouldScrollToBottom = false; } } ngOnDestroy(): void { this.destroyed$.next(); this.destroyed$.complete(); 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(); } } private async startConversation(): Promise { try { this.loading = true; this.error = ''; await this.conversationManager.startConversation(this.sessionId!); this.isConversationActive = true; 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.stopVisualization(); this.snackBar.open('Konuşma sonlandırıldı', 'Close', { duration: 2000 }); } async retryConnection(): Promise { this.error = ''; if (!this.isConversationActive && this.sessionId) { await this.startConversation(); } } clearChat(): void { this.conversationManager.clearMessages(); this.currentTranscription = ''; this.error = ''; } performBargeIn(): void { this.conversationManager.performBargeIn(); this.snackBar.open('Kesme yapıldı', 'Close', { duration: 1000 }); } 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 updateRecordingState(state: ConversationState): void { this.isRecording = state === 'listening'; } private scrollToBottom(): void { try { if (this.scrollContainer?.nativeElement) { const element = this.scrollContainer.nativeElement; element.scrollTop = element.scrollHeight; } } catch(err) { console.error('Scroll error:', err); } } private startVisualization(): void { if (!this.audioVisualizer) return; const canvas = this.audioVisualizer.nativeElement; const ctx = canvas.getContext('2d'); if (!ctx) return; // Get volume level and visualize const draw = async () => { if (!this.isConversationActive) { this.clearVisualization(); return; } ctx.fillStyle = '#333'; ctx.fillRect(0, 0, canvas.width, canvas.height); if (this.isRecording) { // Get actual volume level try { const volume = await this.audioService.getVolumeLevel(); // Draw volume bars ctx.fillStyle = '#4caf50'; const barCount = 50; const barWidth = canvas.width / barCount; for (let i = 0; i < barCount; i++) { const barHeight = Math.random() * volume * canvas.height; const x = i * barWidth; const y = canvas.height - barHeight; ctx.fillRect(x, y, barWidth - 2, barHeight); } } catch (error) { // Fallback to random visualization ctx.fillStyle = '#4caf50'; const barCount = 50; const barWidth = canvas.width / barCount; for (let i = 0; i < barCount; i++) { const barHeight = Math.random() * canvas.height * 0.8; const x = i * barWidth; const y = canvas.height - barHeight; ctx.fillRect(x, y, barWidth - 2, barHeight); } } } this.animationId = requestAnimationFrame(draw); }; draw(); } private stopVisualization(): void { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } this.clearVisualization(); } private clearVisualization(): void { if (!this.audioVisualizer) return; const canvas = this.audioVisualizer.nativeElement; const ctx = canvas.getContext('2d'); if (ctx) { ctx.fillStyle = '#333'; ctx.fillRect(0, 0, canvas.width, canvas.height); } } private cleanupAudio(): void { if (this.currentAudio) { this.currentAudio.pause(); this.currentAudio = null; this.isPlayingAudio = false; } } }
Konuşmaya başlamak için aşağıdaki butona tıklayın